From a7ff52223b3b4ddbd2b439b5873889da236f21f8 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 20 Jun 2016 16:36:47 +0200 Subject: [PATCH 001/200] Ignore .idea folder --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b6f6775..54d3eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ feedgen/tests/tmp_Rssfeed.xml tmp_Atomfeed.xml tmp_Rssfeed.xml +# JetBrains IDE +.idea/ From 71810044bdd06632f5b6c2053dd3fe68236b1410 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 20 Jun 2016 19:08:34 +0200 Subject: [PATCH 002/200] Describe what types are expected for parameters --- feedgen/ext/podcast.py | 25 ++++++++++++++++++++----- feedgen/ext/podcast_entry.py | 17 ++++++++++++++--- feedgen/feed.py | 23 ++++++++++++++++++++++- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 616473e..b03ca37 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -102,6 +102,7 @@ def itunes_author(self, itunes_author=None): feed level, iTunes will use the contents of . :param itunes_author: The author of the podcast. + :type itunes_author: str :returns: The author of the podcast. ''' if not itunes_author is None: @@ -128,9 +129,11 @@ def itunes_category(self, itunes_category=None, itunes_subcategory=None): 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. + :param itunes_category: Category of the podcast, unescaped. + :type itunes_category: str + :param itunes_subcategory: Subcategory of the podcast, unescaped. The subcategory need not be set. + :type itunes_subcategory: str + :returns: Dictionary which has category with key 'cat', and optionally subcategory with key 'sub'. ''' if not itunes_category is None: if not itunes_category in self._itunes_categories.keys(): @@ -163,6 +166,7 @@ def itunes_image(self, itunes_image=None): requests for iTS to be able to automatically update your cover art. :param itunes_image: Image of the podcast. + :type itunes_image: str :returns: Image of the podcast. ''' if not itunes_image is None: @@ -185,7 +189,9 @@ def itunes_explicit(self, itunes_explicit=None): 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. + :param itunes_explicit: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts + as blank. + :type itunes_explicit: str :returns: If the podcast contains explicit material. ''' if not itunes_explicit is None: @@ -205,6 +211,7 @@ def itunes_complete(self, itunes_complete=None): the podcast. :param itunes_complete: If the podcast is complete. + :type itunes_complete: bool :returns: If the podcast is complete. ''' if not itunes_complete is None: @@ -227,6 +234,7 @@ def itunes_new_feed_url(self, itunes_new_feed_url=None): the directory with the new feed URL. :param itunes_new_feed_url: New feed URL. + :type itunes_new_feed_url: bool :returns: New feed URL. ''' if not itunes_new_feed_url is None: @@ -240,7 +248,12 @@ def itunes_owner(self, name=None, email=None): communication specifically about the podcast. It will not be publicly displayed. - :param itunes_owner: The owner of the feed. + Both the name and email are required; you cannot use one or the other alone. + + :param name: The name of the owner of the feed. + :type name: str + :param email: The feed owner's email. + :type email: str :returns: Data of the owner of the feed. ''' if not name is None: @@ -259,6 +272,7 @@ def itunes_subtitle(self, itunes_subtitle=None): displays best if it is only a few words long. :param itunes_subtitle: Subtitle of the podcast. + :type itunes_subtitle: str :returns: Subtitle of the podcast. ''' if not itunes_subtitle is None: @@ -275,6 +289,7 @@ def itunes_summary(self, itunes_summary=None): are used. :param itunes_summary: Summary of the podcast. + :type itunes_summary: str :returns: Summary of the podcast. ''' if not itunes_summary is None: diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 179694c..f517118 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -86,6 +86,7 @@ def itunes_author(self, itunes_author=None): . :param itunes_author: The author of the podcast. + :type itunes_author: str :returns: The author of the podcast. ''' if not itunes_author is None: @@ -95,9 +96,11 @@ def itunes_author(self, itunes_author=None): 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. + from appearing in the iTunes podcast directory. Note that the episode can still be + found by inspecting the XML, thus it is public. :param itunes_block: Block podcast episodes. + :type itunes_block: bool :returns: If the podcast episode is blocked. ''' if not itunes_block is None: @@ -124,6 +127,7 @@ def itunes_image(self, itunes_image=None): requests for iTS to be able to automatically update your cover art. :param itunes_image: Image of the podcast. + :type itunes_image: str :returns: Image of the podcast. ''' if not itunes_image is None: @@ -145,6 +149,7 @@ def itunes_duration(self, itunes_duration=None): numbers farthest to the right are ignored. :param itunes_duration: Duration of the podcast episode. + :type itunes_duration: str or int :returns: Duration of the podcast episode. ''' if not itunes_duration is None: @@ -169,7 +174,9 @@ def itunes_explicit(self, itunes_explicit=None): 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. + :param itunes_explicit: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts + as blank. + :type itunes_explicit: str :returns: If the podcast episode contains explicit material. ''' if not itunes_explicit is None: @@ -185,7 +192,8 @@ def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None): closed captioning support. The two values for this tag are "yes" and "no”. - :param is_closed_captioned: If the episode has closed captioning support. + :param itunes_is_closed_captioned: If the episode has closed captioning support. + :type itunes_is_closed_captioned: bool or str :returns: If the episode has closed captioning support. ''' if not itunes_is_closed_captioned is None: @@ -207,6 +215,7 @@ def itunes_order(self, itunes_order=None): To remove the order from the episode set the order to a value below zero. :param itunes_order: The order of the episode. + :type itunes_order: int :returns: The order of the episode. ''' if not itunes_order is None: @@ -220,6 +229,7 @@ def itunes_subtitle(self, itunes_subtitle=None): subtitle displays best if it is only a few words long. :param itunes_subtitle: Subtitle of the podcast episode. + :type itunes_subtitle: str :returns: Subtitle of the podcast episode. ''' if not itunes_subtitle is None: @@ -236,6 +246,7 @@ def itunes_summary(self, itunes_summary=None): are used. :param itunes_summary: Summary of the podcast episode. + :type itunes_summary: str :returns: Summary of the podcast episode. ''' if not itunes_summary is None: diff --git a/feedgen/feed.py b/feedgen/feed.py index 1d88bc3..93b1b28 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -206,11 +206,15 @@ def atom_str(self, pretty=False, extensions=True, encoding='UTF-8', :param pretty: If the feed should be split into multiple lines and properly indented. + :type pretty: bool :param extensions: Enable or disable the loaded extensions for the xml generation (default: enabled). + :type extensions: bool :param encoding: Encoding used in the XML file (default: UTF-8). + :type encoding: str :param xml_declaration: If an XML declaration should be added to the output (Default: enabled). + :type xml_declaration: bool :returns: String representation of the ATOM feed. ''' feed, doc = self._create_atom(extensions=extensions) @@ -223,13 +227,18 @@ def atom_file(self, filename, extensions=True, pretty=False, '''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. + :type filename: str, fd :param extensions: Enable or disable the loaded extensions for the xml generation (default: enabled). + :type extensions: bool :param pretty: If the feed should be split into multiple lines and properly indented. + :type pretty: bool :param encoding: Encoding used in the XML file (default: UTF-8). + :type encoding: str :param xml_declaration: If an XML declaration should be added to the output (Default: enabled). + :type xml_declaration: bool ''' feed, doc = self._create_atom(extensions=extensions) doc.write(filename, pretty_print=pretty, encoding=encoding, @@ -378,11 +387,15 @@ def rss_str(self, pretty=False, extensions=True, encoding='UTF-8', :param pretty: If the feed should be split into multiple lines and properly indented. + :type pretty: bool :param extensions: Enable or disable the loaded extensions for the xml generation (default: enabled). + :type extensions: bool :param encoding: Encoding used in the XML file (default: UTF-8). + :type encoding: str :param xml_declaration: If an XML declaration should be added to the output (Default: enabled). + :type xml_declaration: bool :returns: String representation of the RSS feed. ''' feed, doc = self._create_rss(extensions=extensions) @@ -395,13 +408,18 @@ def rss_file(self, filename, extensions=True, pretty=False, '''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. + :type filename: str or fd :param extensions: Enable or disable the loaded extensions for the xml generation (default: enabled). + :type extensions: bool :param pretty: If the feed should be split into multiple lines and properly indented. + :type pretty: bool :param encoding: Encoding used in the XML file (default: UTF-8). + :type encoding: str :param xml_declaration: If an XML declaration should be added to the output (Default: enabled). + :type xml_declaration: bool ''' feed, doc = self._create_rss(extensions=extensions) doc.write(filename, pretty_print=pretty, encoding=encoding, @@ -415,6 +433,7 @@ def title(self, title=None): not be blank. :param title: The new title of the feed. + :type title: str :returns: The feeds title. ''' if not title is None: @@ -452,6 +471,7 @@ def updated(self, updated=None): If not set, updated has as value the current date and time. :param updated: The modification date. + :type updated: str or datetime.datetime :returns: Modification date as datetime.datetime ''' if not updated is None: @@ -481,6 +501,7 @@ def lastBuildDate(self, lastBuildDate=None): If not set, lastBuildDate has as value the current date and time. :param lastBuildDate: The modification date. + :type updated: str or datetime.datetime :returns: Modification date as datetime.datetime ''' return self.updated( lastBuildDate ) @@ -565,7 +586,7 @@ def link(self, link=None, replace=False, **kwargs): display purposes. - *length* the length of the resource, in bytes. - RSS only supports one link with URL only. + RSS only supports one link with URL only. If multiple links are given, the last one will be used. :param link: Dict or list of dicts with data. :param replace: Add or replace old data. From 73101a195f7928935327899f9b897c2629f162d3 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 20 Jun 2016 20:11:28 +0200 Subject: [PATCH 003/200] Include the value in question in error messages By including the value of the parameter which failed to validate, the user of the library may more easily identify the cause of the bug, without having to fire up the Python debugger. --- feedgen/ext/podcast.py | 9 +++++---- feedgen/ext/podcast_entry.py | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index b03ca37..401626a 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -137,11 +137,12 @@ def itunes_category(self, itunes_category=None, itunes_subcategory=None): ''' if not itunes_category is None: if not itunes_category in self._itunes_categories.keys(): - raise ValueError('Invalid category') + raise ValueError('Invalid category %s' % itunes_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') + raise ValueError('Invalid subcategory "%s" under category "%s"' + % (itunes_subcategory, itunes_category)) cat['sub'] = itunes_subcategory self.__itunes_category = cat return self.__itunes_category @@ -196,7 +197,7 @@ def itunes_explicit(self, itunes_explicit=None): ''' if not itunes_explicit is None: if not itunes_explicit in ('', 'yes', 'no', 'clean'): - raise ValueError('Invalid value for explicit tag') + raise ValueError('Invalid value "%s" for explicit tag' % itunes_explicit) self.__itunes_explicit = itunes_explicit return self.__itunes_explicit @@ -216,7 +217,7 @@ def itunes_complete(self, itunes_complete=None): ''' if not itunes_complete is None: if not itunes_complete in ('yes', 'no', '', True, False): - raise ValueError('Invalid value for complete tag') + raise ValueError('Invalid value "%s" for complete tag' % itunes_complete) if itunes_complete == True: itunes_complete = 'yes' if itunes_complete == False: diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index f517118..8ee2fc1 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -132,7 +132,7 @@ def itunes_image(self, itunes_image=None): ''' 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') + ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) self.__itunes_image = itunes_image return self.__itunes_image @@ -156,7 +156,7 @@ 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') + ValueError('Invalid duration format "%s"' % itunes_duration) self.__itunes_duration = itunes_duration return self.itunes_duration @@ -181,7 +181,7 @@ def itunes_explicit(self, itunes_explicit=None): ''' if not itunes_explicit is None: if not itunes_explicit in ('', 'yes', 'no', 'clean'): - raise ValueError('Invalid value for explicit tag') + raise ValueError('Invalid value "%s" for explicit tag' % itunes_explicit) self.__itunes_explicit = itunes_explicit return self.__itunes_explicit From c186ac79ceb8c73c83cff08a6afaff6c1a2a9e21 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 20 Jun 2016 20:16:01 +0200 Subject: [PATCH 004/200] Include version and uri in generator value for RSS There is a convention to include the generator's version and uri in the generator text in RSS. The code is updated to use this convention, when those values are given. Thus, they are used for both Atom and RSS (although unstructured in RSS). --- feedgen/feed.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 1d88bc3..f4e5dcb 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -710,7 +710,9 @@ def generator(self, generator=None, version=None, uri=None): self.__atom_generator['version'] = version if not uri is None: self.__atom_generator['uri'] = uri - self.__rss_generator = generator + self.__rss_generator = generator + \ + (("/" + str(version)) if version is not None else "") + \ + ((" " + uri) if uri else "") return self.__atom_generator From cd89f08790fec7ef6116e61a246163d391288d73 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 20 Jun 2016 20:25:49 +0200 Subject: [PATCH 005/200] Don't require image file extension to be lower-case, allow ".jpeg" I don't know exactly what (implicit) requirements iTunes have for the image URLs, but I guess they will accept .jpeg and file extensions which aren't lower-case. --- feedgen/ext/podcast.py | 3 ++- feedgen/ext/podcast_entry.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 616473e..78ee4c1 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -166,7 +166,8 @@ def itunes_image(self, itunes_image=None): :returns: Image of the podcast. ''' if not itunes_image is None: - if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ): + lowercase_itunes_image = itunes_image.lower() + if not ( lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png')) ): ValueError('Image file must be png or jpg') self.__itunes_image = itunes_image return self.__itunes_image diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 179694c..6fe7da0 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -127,7 +127,8 @@ def itunes_image(self, itunes_image=None): :returns: Image of the podcast. ''' if not itunes_image is None: - if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ): + lowercase_itunes_image = itunes_image.lower() + if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): ValueError('Image file must be png or jpg') self.__itunes_image = itunes_image return self.__itunes_image From efcf378fc2ef91b210db1c8ad425d7b856750491 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 20 Jun 2016 20:40:12 +0200 Subject: [PATCH 006/200] Print the faulty error extension in error message --- 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 401626a..b584a27 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -172,7 +172,7 @@ def itunes_image(self, itunes_image=None): ''' 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') + ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) self.__itunes_image = itunes_image return self.__itunes_image From 14c4b4c2e643fda1e17523891f086885258c7c26 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 20 Jun 2016 23:23:05 +0200 Subject: [PATCH 007/200] Fix validation of itunes_image() --- feedgen/ext/podcast.py | 2 +- feedgen/ext/podcast_entry.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index b584a27..f6fdf88 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -172,7 +172,7 @@ def itunes_image(self, itunes_image=None): ''' if not itunes_image is None: if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ): - ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) + raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) self.__itunes_image = itunes_image return self.__itunes_image diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 8ee2fc1..71e867c 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -132,7 +132,7 @@ def itunes_image(self, itunes_image=None): ''' if not itunes_image is None: if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ): - ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) + raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) self.__itunes_image = itunes_image return self.__itunes_image From b279c0c2a027103250923aca0a3c7018b8d32cd7 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 21 Jun 2016 21:26:38 +0200 Subject: [PATCH 008/200] Replace tabs with spaces --- doc/_static/lernfunk.css | 72 +- doc/_static/theme_extras.js | 52 +- doc/conf.py | 62 +- feedgen/__init__.py | 200 +-- feedgen/__main__.py | 190 +-- feedgen/compat.py | 4 +- feedgen/entry.py | 1282 +++++++++---------- feedgen/ext/__init__.py | 6 +- feedgen/ext/base.py | 52 +- feedgen/ext/dc.py | 804 ++++++------ feedgen/ext/podcast.py | 622 +++++----- feedgen/ext/podcast_entry.py | 486 ++++---- feedgen/feed.py | 2260 +++++++++++++++++----------------- feedgen/tests/test_entry.py | 160 +-- feedgen/tests/test_feed.py | 522 ++++---- feedgen/util.py | 108 +- feedgen/version.py | 8 +- setup.py | 60 +- 18 files changed, 3468 insertions(+), 3482 deletions(-) diff --git a/doc/_static/lernfunk.css b/doc/_static/lernfunk.css index 592129e..62add54 100644 --- a/doc/_static/lernfunk.css +++ b/doc/_static/lernfunk.css @@ -1,91 +1,91 @@ @import url("haiku.css"); dl.function > dt, dl.method > dt,dl.attribute > dt, dl.class > dt, dl.get > dt, dl.post > dt, dl.put > dt, dl.delete > dt, dl.data > dt, div.apititle { - padding: 5px; - padding-left: 15px; - border-radius: 3px; - border-top: 1px solid gray; - background-color: silver; + padding: 5px; + padding-left: 15px; + border-radius: 3px; + border-top: 1px solid gray; + background-color: silver; } dl.class { - border-left: 5px solid silver; - padding-bottom: 10px; - margin-bottom: 30px; + border-left: 5px solid silver; + padding-bottom: 10px; + margin-bottom: 30px; } table.docutils td, table.docutils th, table.docutils tr { - border: none; + border: none; } table.docutils thead tr { - border-bottom: 1px solid gray; + border-bottom: 1px solid gray; } table.docutils { - border-top: 2px solid gray; - border-bottom: 2px solid gray; + border-top: 2px solid gray; + border-bottom: 2px solid gray; } div.apitoc { - border-bottom: 2px solid silver; - border-left: 10px solid silver; + border-bottom: 2px solid silver; + border-left: 10px solid silver; } div.apitoc a { - display: block; - padding: 0px; - padding-left: 10px; - color: black; + display: block; + padding: 0px; + padding-left: 10px; + color: black; } div.apitoc a:hover { - background-color: #eee; + background-color: #eee; } div.apitoc a.second { - padding-left: 25px; + padding-left: 25px; } div.apitoc a.partOfClass { - padding-left: 25px; - border-left: 3px solid silver; - margin-left: 25px; + padding-left: 25px; + border-left: 3px solid silver; + margin-left: 25px; } div.apitoc span.apilnclassname, div.apitoc big, div.apitoc em { - font-weight: lighter; + font-weight: lighter; } div.apitoc big, div.apitoc em { - color: #666; + color: #666; } div.apitoc span.apilnname { - font-weight: bold; + font-weight: bold; } a.headerlink { - color: gray; + color: gray; } /* li.toctree-l3 { - display: inline-block; - min-width: 200px; - padding: 0px; - margin: 0px; + display: inline-block; + min-width: 200px; + padding: 0px; + margin: 0px; } li.toctree-l3 a { - display: block; - margin: 1px 10px; - padding: 3px 10px; - border-radius: 3px; + display: block; + margin: 1px 10px; + padding: 3px 10px; + border-radius: 3px; } li.toctree-l3 a:hover { - background-color: #eee; + background-color: #eee; } */ diff --git a/doc/_static/theme_extras.js b/doc/_static/theme_extras.js index 73b00c3..5144aa2 100644 --- a/doc/_static/theme_extras.js +++ b/doc/_static/theme_extras.js @@ -1,28 +1,28 @@ $(document).ready(function() { - $('.headerlink').each(function( index ) { - var type = $(this).parent().get(0).nodeName - if (type == 'H1') { - var name = $(this).parent().get(0).childNodes[0].data; - var ln = $(this).attr('href'); - $('div.apitoc').append(''+name+''); - } else if (type == 'H2') { - var name = $(this).parent().get(0).childNodes[0].data; - var ln = $(this).attr('href'); - $('div.apitoc').append(''+name+''); - } else if (type == 'DT') { - //var name = $(this).parent().text().replace('¶', ''); - var name = $(this).parent().html().replace(//g, '') - .replace(//g, ''); - var ln = $(this).attr('href'); - var p = $(this).parent().parent(); - if ( p.hasClass('method') || p.hasClass('attribute') ) { - $('div.apitoc').append(''+name+''); - } else { - $('div.apitoc').append(''+name+''); - } - } else { - // alert( type ); - } - }); + $('.headerlink').each(function( index ) { + var type = $(this).parent().get(0).nodeName + if (type == 'H1') { + var name = $(this).parent().get(0).childNodes[0].data; + var ln = $(this).attr('href'); + $('div.apitoc').append(''+name+''); + } else if (type == 'H2') { + var name = $(this).parent().get(0).childNodes[0].data; + var ln = $(this).attr('href'); + $('div.apitoc').append(''+name+''); + } else if (type == 'DT') { + //var name = $(this).parent().text().replace('¶', ''); + var name = $(this).parent().html().replace(//g, '') + .replace(//g, ''); + var ln = $(this).attr('href'); + var p = $(this).parent().parent(); + if ( p.hasClass('method') || p.hasClass('attribute') ) { + $('div.apitoc').append(''+name+''); + } else { + $('div.apitoc').append(''+name+''); + } + } else { + // alert( type ); + } + }); }); diff --git a/doc/conf.py b/doc/conf.py index 0795320..087d6e5 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -21,10 +21,10 @@ # 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'] @@ -185,8 +185,8 @@ # 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 @@ -215,8 +215,8 @@ # 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. @@ -229,9 +229,9 @@ # (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. @@ -252,32 +252,32 @@ 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 + ''' + 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('autodoc-process-docstring', process_docstring) + app.connect('source-read', substitute_link) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index aef2c72..37e3103 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -1,136 +1,136 @@ # -*- 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 Doe', '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 4463ea6..08f9140 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -1,116 +1,116 @@ # -*- coding: utf-8 -*- ''' - feedgen - ~~~~~~~ + feedgen + ~~~~~~~ - :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 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) + '''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) 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('podcast') ): - print_enc ('Usage: %s ( .atom | atom | .rss | rss | podcast )' % \ - '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 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 ('') - exit() + if len(sys.argv) != 2 or not ( + sys.argv[1].endswith('rss') \ + or sys.argv[1].endswith('atom') \ + or sys.argv[1].endswith('podcast') ): + print_enc ('Usage: %s ( .atom | atom | .rss | rss | podcast )' % \ + '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 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 ('') + exit() - arg = sys.argv[1] + arg = sys.argv[1] - fg = FeedGenerator() - fg.id('http://lernfunk.de/_MEDIAID_123') - fg.title('Testfeed') - fg.author( {'name':'Lars Kiesow','email':'lkiesow@uos.de'} ) - fg.link( href='http://example.com', rel='alternate' ) - fg.category(term='test') - fg.contributor( name='Lars Kiesow', email='lkiesow@uos.de' ) - fg.contributor( name='John Doe', email='jdoe@example.com' ) - fg.icon('http://ex.com/icon.jpg') - fg.logo('http://ex.com/logo.jpg') - fg.rights('cc-by') - fg.subtitle('This is a cool feed!') - fg.link( href='http://larskiesow.de/test.atom', rel='self' ) - fg.language('de') - fe = fg.add_entry() - fe.id('http://lernfunk.de/_MEDIAID_123#1') - fe.title('First Element') - fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen - aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si ista - mala sunt, placet. Aut etiam, ut vestitum, sic sententiam habeas aliam - domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas - occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, - verba.''') - fe.summary(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…') - fe.link( href='http://example.com', rel='alternate' ) - fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) + fg = FeedGenerator() + fg.id('http://lernfunk.de/_MEDIAID_123') + fg.title('Testfeed') + fg.author( {'name':'Lars Kiesow','email':'lkiesow@uos.de'} ) + fg.link( href='http://example.com', rel='alternate' ) + fg.category(term='test') + fg.contributor( name='Lars Kiesow', email='lkiesow@uos.de' ) + fg.contributor( name='John Doe', email='jdoe@example.com' ) + fg.icon('http://ex.com/icon.jpg') + fg.logo('http://ex.com/logo.jpg') + fg.rights('cc-by') + fg.subtitle('This is a cool feed!') + fg.link( href='http://larskiesow.de/test.atom', rel='self' ) + fg.language('de') + fe = fg.add_entry() + fe.id('http://lernfunk.de/_MEDIAID_123#1') + fe.title('First Element') + fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen + aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si ista + mala sunt, placet. Aut etiam, ut vestitum, sic sententiam habeas aliam + domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas + occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, + verba.''') + fe.summary(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…') + fe.link( href='http://example.com', rel='alternate' ) + fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) - if arg == 'atom': - print_enc (fg.atom_str(pretty=True)) - elif arg == 'rss': - print_enc (fg.rss_str(pretty=True)) - elif arg == 'podcast': - # Load the podcast extension. It will automatically be loaded for all - # entries in the feed, too. Thus also for our “fe”. - fg.load_extension('podcast') - fg.podcast.itunes_author('Lars Kiesow') - fg.podcast.itunes_category('Technology', 'Podcasting') - fg.podcast.itunes_explicit('no') - fg.podcast.itunes_complete('no') - fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') - fg.podcast.itunes_owner('John Doe', 'john@example.com') - fg.podcast.itunes_summary('Lorem ipsum dolor sit amet, ' + \ - 'consectetur adipiscing elit. ' + \ - 'Verba tu fingas et ea dicas, quae non sentias?') - fe.podcast.itunes_author('Lars Kiesow') - print_enc (fg.rss_str(pretty=True)) + if arg == 'atom': + print_enc (fg.atom_str(pretty=True)) + elif arg == 'rss': + print_enc (fg.rss_str(pretty=True)) + elif arg == 'podcast': + # Load the podcast extension. It will automatically be loaded for all + # entries in the feed, too. Thus also for our “fe”. + fg.load_extension('podcast') + fg.podcast.itunes_author('Lars Kiesow') + fg.podcast.itunes_category('Technology', 'Podcasting') + fg.podcast.itunes_explicit('no') + fg.podcast.itunes_complete('no') + fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') + fg.podcast.itunes_owner('John Doe', 'john@example.com') + fg.podcast.itunes_summary('Lorem ipsum dolor sit amet, ' + \ + 'consectetur adipiscing elit. ' + \ + 'Verba tu fingas et ea dicas, quae non sentias?') + fe.podcast.itunes_author('Lars Kiesow') + print_enc (fg.rss_str(pretty=True)) - elif arg.startswith('dc.'): - fg.load_extension('dc') - fg.dc.dc_contributor('Lars Kiesow') - if arg.endswith('.atom'): - print_enc (fg.atom_str(pretty=True)) - else: - print_enc (fg.rss_str(pretty=True)) + elif arg.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.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('atom'): + fg.atom_file(arg) - elif arg.endswith('rss'): - fg.rss_file(arg) + elif arg.endswith('rss'): + fg.rss_file(arg) diff --git a/feedgen/compat.py b/feedgen/compat.py index dc9127e..5bd7b57 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 diff --git a/feedgen/entry.py b/feedgen/entry.py index bf50357..b8baefd 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,640 +17,640 @@ 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 = {} - - - 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'): - email = etree.SubElement(author, 'url') - email.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) - # 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'): - email = etree.SubElement(contrib, 'url') - email.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 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 - 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} + '''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 = {} + + + 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'): + email = etree.SubElement(author, 'url') + email.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) + # 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'): + email = etree.SubElement(contrib, 'url') + email.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 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 + 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} 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..4889ce0 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..631f725 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -1,16 +1,16 @@ # -*- 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, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' from lxml import etree @@ -18,402 +18,402 @@ 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 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 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 251a490..61bf4b6 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 @@ -15,316 +15,302 @@ 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' - - 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'): - subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) - subcategory.attrib['text'] = self.__itunes_category['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. - :type itunes_author: str - :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, itunes_subcategory=None): - '''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, unescaped. - :type itunes_category: str - :param itunes_subcategory: Subcategory of the podcast, unescaped. The subcategory need not be set. - :type itunes_subcategory: str - :returns: Dictionary which has category with key 'cat', and optionally subcategory with key 'sub'. - ''' - if not itunes_category is None: - if not itunes_category in self._itunes_categories.keys(): - raise ValueError('Invalid category %s' % itunes_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 "%s" under category "%s"' - % (itunes_subcategory, itunes_category)) - cat['sub'] = itunes_subcategory - self.__itunes_category = 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. - :type itunes_image: str - :returns: Image of the podcast. - ''' - if not itunes_image is None: - lowercase_itunes_image = itunes_image.lower() - if not ( lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png')) ): - raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) - 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts - as blank. - :type itunes_explicit: str - :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 "%s" for explicit tag' % itunes_explicit) - 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. - :type itunes_complete: bool - :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 "%s" for complete tag' % itunes_complete) - 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. - :type itunes_new_feed_url: bool - :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. - - Both the name and email are required; you cannot use one or the other alone. - - :param name: The name of the owner of the feed. - :type name: str - :param email: The feed owner's email. - :type email: str - :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. - :type itunes_subtitle: str - :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. - :type itunes_summary: str - :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 not self.__itunes_block is None: + 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'): + subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) + subcategory.attrib['text'] = self.__itunes_category['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. + :type itunes_author: str + :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, itunes_subcategory=None): + '''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, unescaped. + :type itunes_category: str + :param itunes_subcategory: Subcategory of the podcast, unescaped. The subcategory need not be set. + :type itunes_subcategory: str + :returns: Dictionary which has category with key 'cat', and optionally subcategory with key 'sub'. + ''' + if not itunes_category is None: + if not itunes_category in self._itunes_categories.keys(): + raise ValueError('Invalid category %s' % itunes_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 "%s" under category "%s"' + % (itunes_subcategory, itunes_category)) + cat['sub'] = itunes_subcategory + self.__itunes_category = 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. + :type itunes_image: str + :returns: Image of the podcast. + ''' + if not itunes_image is None: + lowercase_itunes_image = itunes_image.lower() + if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): + raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) + 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts + as blank. + :type itunes_explicit: str + :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 "%s" for explicit tag' % itunes_explicit) + 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. + :type itunes_complete: bool + :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 "%s" for complete tag' % itunes_complete) + 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. + :type itunes_new_feed_url: bool + :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. + + Both the name and email are required; you cannot use one or the other alone. + + :param name: The name of the owner of the feed. + :type name: str + :param email: The feed owner's email. + :type email: str + :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. + :type itunes_subtitle: str + :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. + :type itunes_summary: str + :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': [] + } diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 0f3e56d..ce1a85d 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, 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,241 +15,241 @@ 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. - :type itunes_author: str - :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. Note that the episode can still be - found by inspecting the XML, thus it is public. - - :param itunes_block: Block podcast episodes. - :type itunes_block: bool - :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. - :type itunes_image: str - :returns: Image of the podcast. - ''' - if not itunes_image is None: - lowercase_itunes_image = itunes_image.lower() - if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) - 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. - :type itunes_duration: str or int - :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 "%s"' % itunes_duration) - 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts - as blank. - :type itunes_explicit: str - :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 "%s" for explicit tag' % itunes_explicit) - 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 itunes_is_closed_captioned: If the episode has closed captioning support. - :type itunes_is_closed_captioned: bool or str - :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. - :type itunes_order: int - :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. - :type itunes_subtitle: str - :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. - :type itunes_summary: str - :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 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. + :type itunes_author: str + :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. Note that the episode can still be + found by inspecting the XML, thus it is public. + + :param itunes_block: Block podcast episodes. + :type itunes_block: bool + :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. + :type itunes_image: str + :returns: Image of the podcast. + ''' + if not itunes_image is None: + lowercase_itunes_image = itunes_image.lower() + if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): + raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) + 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. + :type itunes_duration: str or int + :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 "%s"' % itunes_duration) + 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts + as blank. + :type itunes_explicit: str + :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 "%s" for explicit tag' % itunes_explicit) + 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 itunes_is_closed_captioned: If the episode has closed captioning support. + :type itunes_is_closed_captioned: bool or str + :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. + :type itunes_order: int + :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. + :type itunes_subtitle: str + :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. + :type itunes_summary: str + :returns: Summary of the podcast episode. + ''' + if not itunes_summary is None: + self.__itunes_summary = itunes_summary + return self.__itunes_summary diff --git a/feedgen/feed.py b/feedgen/feed.py index 8099b87..004f460 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, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' @@ -24,1129 +24,1129 @@ class FeedGenerator(object): - '''FeedGenerator for generating ATOM and RSS feeds. - ''' - - - def __init__(self): - self.__extensions = {} - 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', - 'url' :'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: - __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'): - email = etree.SubElement(author, 'url') - email.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'): - email = etree.SubElement(contrib, 'url') - email.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. - :type pretty: bool - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :type extensions: bool - :param encoding: Encoding used in the XML file (default: UTF-8). - :type encoding: str - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :type xml_declaration: bool - :returns: String representation of the ATOM feed. - ''' - 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. - :type filename: str, fd - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :type extensions: bool - :param pretty: If the feed should be split into multiple lines and - properly indented. - :type pretty: bool - :param encoding: Encoding used in the XML file (default: UTF-8). - :type encoding: str - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :type xml_declaration: bool - ''' - 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. - :type pretty: bool - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :type extensions: bool - :param encoding: Encoding used in the XML file (default: UTF-8). - :type encoding: str - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :type xml_declaration: bool - :returns: String representation of the RSS feed. - ''' - 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. - :type filename: str or fd - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :type extensions: bool - :param pretty: If the feed should be split into multiple lines and - properly indented. - :type pretty: bool - :param encoding: Encoding used in the XML file (default: UTF-8). - :type encoding: str - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :type xml_declaration: bool - ''' - 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. - :type title: str - :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. - :type updated: str or datetime.datetime - :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. - :type updated: str or datetime.datetime - :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. If multiple links are given, the last one will be used. - - :param link: Dict or list of dicts with data. - :param replace: Add or replace old 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 + \ - (("/" + str(version)) if version is not None else "") + \ - ((" " + uri) if uri else "") - 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.load_extension( extname, 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.load_extension( extname, 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:] + '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 + '''FeedGenerator for generating ATOM and RSS feeds. + ''' + + + def __init__(self): + self.__extensions = {} + 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', + 'url' :'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: + __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'): + email = etree.SubElement(author, 'url') + email.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'): + email = etree.SubElement(contrib, 'url') + email.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. + :type pretty: bool + :param extensions: Enable or disable the loaded extensions for the xml + generation (default: enabled). + :type extensions: bool + :param encoding: Encoding used in the XML file (default: UTF-8). + :type encoding: str + :param xml_declaration: If an XML declaration should be added to the + output (Default: enabled). + :type xml_declaration: bool + :returns: String representation of the ATOM feed. + ''' + 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. + :type filename: str, fd + :param extensions: Enable or disable the loaded extensions for the xml + generation (default: enabled). + :type extensions: bool + :param pretty: If the feed should be split into multiple lines and + properly indented. + :type pretty: bool + :param encoding: Encoding used in the XML file (default: UTF-8). + :type encoding: str + :param xml_declaration: If an XML declaration should be added to the + output (Default: enabled). + :type xml_declaration: bool + ''' + 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. + :type pretty: bool + :param extensions: Enable or disable the loaded extensions for the xml + generation (default: enabled). + :type extensions: bool + :param encoding: Encoding used in the XML file (default: UTF-8). + :type encoding: str + :param xml_declaration: If an XML declaration should be added to the + output (Default: enabled). + :type xml_declaration: bool + :returns: String representation of the RSS feed. + ''' + 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. + :type filename: str or fd + :param extensions: Enable or disable the loaded extensions for the xml + generation (default: enabled). + :type extensions: bool + :param pretty: If the feed should be split into multiple lines and + properly indented. + :type pretty: bool + :param encoding: Encoding used in the XML file (default: UTF-8). + :type encoding: str + :param xml_declaration: If an XML declaration should be added to the + output (Default: enabled). + :type xml_declaration: bool + ''' + 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. + :type title: str + :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. + :type updated: str or datetime.datetime + :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. + :type updated: str or datetime.datetime + :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. If multiple links are given, the last one will be used. + + :param link: Dict or list of dicts with data. + :param replace: Add or replace old 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 + \ + (("/" + str(version)) if version is not None else "") + \ + ((" " + uri) if uri else "") + 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.load_extension( extname, 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.load_extension( extname, 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:] + '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 diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index d2fef27..40218bc 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -12,83 +12,83 @@ 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 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 diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index bcfe506..a38b3a5 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -13,270 +13,270 @@ 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} + 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.linkHref = 'http://example.com' + self.linkRel = 'alternate' - self.logo = 'http://ex.com/logo.jpg' - self.subtitle = 'This is a cool feed!' + self.logo = 'http://ex.com/logo.jpg' + self.subtitle = 'This is a cool feed!' - self.link2Href = 'http://larskiesow.de/test.atom' - self.link2Rel = 'self' + self.link2Href = 'http://larskiesow.de/test.atom' + self.link2Rel = 'self' - self.language = 'en' - - self.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}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, 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.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): - - 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 + 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) + 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}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, 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.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): + + 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() diff --git a/feedgen/util.py b/feedgen/util.py index c7c9454..5065759 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -1,72 +1,72 @@ # -*- 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 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 70299ee..e476f36 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- ''' - feedgen.version - ~~~~~~~~~~~~~~~ + feedgen.version + ~~~~~~~~~~~~~~~ - :copyright: 2013-2015, Lars Kiesow + :copyright: 2013-2015, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' diff --git a/setup.py b/setup.py index 059dcb7..aed3202 100755 --- a/setup.py +++ b/setup.py @@ -5,36 +5,36 @@ import feedgen.version setup( - name = 'feedgen', - packages = ['feedgen', 'feedgen/ext'], - 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', 'dateutils'], - classifiers = [ - 'Development Status :: 4 - Beta', - '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 = '''\ + name = 'feedgen', + packages = ['feedgen', 'feedgen/ext'], + 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', 'dateutils'], + classifiers = [ + 'Development Status :: 4 - Beta', + '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 ============= From ad62f1cc3d9f4c2f67314213b6a4ca6132957235 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 21 Jun 2016 21:35:43 +0200 Subject: [PATCH 009/200] Remove all extensions except podcast --- Makefile | 1 - doc/ext/api.ext.dc.rst | 7 - feedgen/ext/dc.py | 419 -------------------------------- feedgen/ext/syndication.py | 60 ----- feedgen/tests/test_extension.py | 50 ---- feedgen/tests/test_feed.py | 4 - 6 files changed, 541 deletions(-) delete mode 100644 doc/ext/api.ext.dc.rst delete mode 100644 feedgen/ext/dc.py delete mode 100644 feedgen/ext/syndication.py delete mode 100644 feedgen/tests/test_extension.py diff --git a/Makefile b/Makefile index b61b9b6..8c6e697 100644 --- a/Makefile +++ b/Makefile @@ -47,5 +47,4 @@ publish: sdist 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 diff --git a/doc/ext/api.ext.dc.rst b/doc/ext/api.ext.dc.rst deleted file mode 100644 index 7e01cf3..0000000 --- a/doc/ext/api.ext.dc.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. raw:: html - -
Contents
-
- -.. automodule:: feedgen.ext.dc - :members: diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py deleted file mode 100644 index 631f725..0000000 --- a/feedgen/ext/dc.py +++ /dev/null @@ -1,419 +0,0 @@ -# -*- coding: utf-8 -*- -''' -feedgen.ext.dc - ~~~~~~~~~~~~~~~~~~~ - - Extends the FeedGenerator to add Dubline Core Elements to the feeds. - - Descriptions partly taken from - http://dublincore.org/documents/dcmi-terms/#elements-coverage - - :copyright: 2013, Lars Kiesow - - :license: FreeBSD and LGPL, see license.* for more details. -''' - -from lxml import etree -from feedgen.ext.base import BaseExtension, BaseEntryExtension - - -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 - -class DcExtension(DcBaseExtension): - '''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 diff --git a/feedgen/ext/syndication.py b/feedgen/ext/syndication.py deleted file mode 100644 index 3b17e0c..0000000 --- a/feedgen/ext/syndication.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- 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 deleted file mode 100644 index 53333b7..0000000 --- a/feedgen/tests/test_extension.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- 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 diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index a38b3a5..4140e18 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -236,10 +236,6 @@ 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): feed = etree.fromstring(rssString) From 6c0ebd21bc1ca11d0b548dcb9945ead47dad98a1 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 21 Jun 2016 23:19:34 +0200 Subject: [PATCH 010/200] Remove support for ATOM Note that this is still work in progress!! I'm removing ATOM support because this fork will focus on the podcast capabilities, and podcasts use RSS, not ATOM. This also helps reduce confusion in the API, which was designed to support both formats simultaniously. --- feedgen/__init__.py | 35 +-- feedgen/__main__.py | 54 +--- feedgen/entry.py | 431 ++++---------------------------- feedgen/ext/base.py | 10 - feedgen/feed.py | 485 +++++------------------------------- feedgen/tests/test_entry.py | 17 +- feedgen/tests/test_feed.py | 150 ++--------- readme.md | 64 ++--- 8 files changed, 165 insertions(+), 1081 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 37e3103..80f061a 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- """ ======= - feedgen + feedgen (forked) ======= - 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 podcast feeds in RSS format. + It has support for extensions. :copyright: 2013 by Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. @@ -16,18 +15,17 @@ 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 >>> 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.image('http://ex.com/logo.jpg') + >>> fg.description('This is a cool feed!') + >>> fg.link( href='http://larskiesow.de/test.atom') >>> fg.language('en') Note that for the methods which set fields that can occur more than once in @@ -47,11 +45,9 @@ Generate the Feed ----------------- - After that you can generate both RSS or ATOM by calling the respective method:: + After that you can generate RSS by calling:: - >>> 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 @@ -80,12 +76,12 @@ 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') 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 + “SomextExtension” which is required to have at least one method, + `extend_rss(...)`. 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 @@ -93,12 +89,7 @@ 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. - - **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. @@ -120,7 +111,7 @@ 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 + Of cause you can still produce a normal 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`. diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 08f9140..090d3e9 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -25,41 +25,29 @@ def print_enc(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('podcast') ): - print_enc ('Usage: %s ( .atom | atom | .rss | rss | podcast )' % \ + print_enc ('Usage: %s ( .rss | rss | podcast )' % \ '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 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 ('') 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.link( href='http://example.com') 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.image('http://ex.com/logo.jpg') + fg.copyright('cc-by') + fg.description('This is a cool feed!') fg.language('de') fe = fg.add_entry() - fe.id('http://lernfunk.de/_MEDIAID_123#1') + fe.guid('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 @@ -67,13 +55,11 @@ 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(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…') - fe.link( href='http://example.com', rel='alternate' ) + fe.description(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…') + fe.link( href='http://example.com') fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) - if arg == 'atom': - print_enc (fg.atom_str(pretty=True)) - elif arg == 'rss': + if arg == 'rss': print_enc (fg.rss_str(pretty=True)) elif arg == 'podcast': # Load the podcast extension. It will automatically be loaded for all @@ -90,27 +76,5 @@ 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.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/entry.py b/feedgen/entry.py index b8baefd..a0d73e8 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -7,6 +7,7 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' +import collections from lxml import etree from datetime import datetime @@ -22,25 +23,6 @@ class FeedEntry(object): ''' 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 @@ -58,122 +40,6 @@ def __init__(self): self.__extensions = {} - 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'): - email = etree.SubElement(author, 'url') - email.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) - # 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'): - email = etree.SubElement(contrib, 'url') - email.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') @@ -224,8 +90,7 @@ def rss_entry(self, extensions=True): if extensions: for ext in self.__extensions.values() or []: - if ext.get('rss'): - ext['inst'].extend_rss(entry) + ext['inst'].extend_rss(entry) return entry @@ -233,71 +98,31 @@ def rss_entry(self, extensions=True): 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. + readable title for the entry. Title is mandatory 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 + return self.__rss_title 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. + the item. :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 + if not guid is None: + self.__rss_guid = guid + return self.__rss_guid 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. + '''Get or set autor data. An author element is a dict containing a name and + an email adress. Email is mandatory. This method can be called with: - the fields of an author as keyword arguments @@ -306,7 +131,6 @@ def author(self, author=None, replace=False, **kwargs): 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. @@ -328,22 +152,17 @@ def author(self, author=None, replace=False, **kwargs): 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 + if replace or self.__rss_author is None: + self.__rss_author = [] + authors = ensure_format( author, + set(['name', 'email']), set(['email'])) + self.__rss_author += ['%s (%s)' % ( a['email'], a['name'] ) for a in authors] + return self.__rss_author + + + def content(self, content=None, type=None): + '''Get or set the content of the entry which contains or links to the + complete content of the entry. If the content is set (not linked) it will also set rss:description. :param content: The content of the feed entry. @@ -351,134 +170,47 @@ def content(self, content=None, src=None, type=None): :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} + if not content is None: self.__rss_content = {'content':content} if not type is None: - self.__atom_content['type'] = type self.__rss_content['type'] = type - return self.__atom_content + return self.__rss_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. + def link(self, href=None): + '''Get or set the link to the full version of this episode description. - 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. + :param href: the URI of the referenced resource (typically a Web page) + :returns: The current link URI. ''' - 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 + if not href is None: + self.__rss_link = href + return self.__rss_link - def description(self, description=None, isSummary=False): + def description(self, description=None): '''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. + '''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. @@ -490,51 +222,17 @@ def category(self, category=None, replace=False, **kwargs): 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 = {} + if replace or self.__rss_category is None: + self.__rss_category = [] + if isinstance(category, collections.Mapping): + category = [category] + for cat in category: + rss_cat = dict() 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 + self.__rss_category.append(rss_cat) + return self.__rss_category def published(self, published=None): @@ -555,10 +253,9 @@ def published(self, published=None): 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 + return self.__rss_pubDate def pubdate(self, pubDate=None): @@ -569,22 +266,9 @@ def pubdate(self, pubDate=None): 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. + page for the item. :param comments: URL to the comments page. :returns: URL to the comments page. @@ -596,11 +280,7 @@ def comments(self, comments=None): 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. + is attached to this item. :param url: URL of the media object. :param length: Size of the media in bytes. @@ -608,29 +288,14 @@ def enclosure(self, url=None, length=None, type=None): :returns: Data of the enclosure element. ''' if not url is None: - self.link( href=url, rel='enclosure', type=type, length=length ) + self.__rss_enclosure = {'url': url, 'length': length, 'type': type} 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): + def load_extension(self, name): '''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): @@ -653,4 +318,4 @@ def load_extension(self, name, atom=True, rss=True): ext = getattr(extmod, extname) extinst = ext() setattr(self, name, extinst) - self.__extensions[name] = {'inst':extinst,'atom':atom,'rss':rss} + self.__extensions[name] = {'inst':extinst} diff --git a/feedgen/ext/base.py b/feedgen/ext/base.py index 4889ce0..0c8555f 100644 --- a/feedgen/ext/base.py +++ b/feedgen/ext/base.py @@ -28,16 +28,6 @@ def extend_rss(self, feed): return feed - 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 - - class BaseEntryExtension(BaseExtension): '''Basic FeedEntry extension. ''' diff --git a/feedgen/feed.py b/feedgen/feed.py index 004f460..da7a36b 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -18,13 +18,14 @@ import feedgen.version import sys from feedgen.compat import string_types +import collections _feedgen_version = feedgen.version.version_str class FeedGenerator(object): - '''FeedGenerator for generating ATOM and RSS feeds. + '''FeedGenerator for generating RSS feeds. ''' @@ -32,38 +33,13 @@ def __init__(self): self.__extensions = {} 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', - 'url' :'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_author = None self.__rss_category = None self.__rss_cloud = None self.__rss_copyright = None @@ -85,165 +61,6 @@ def __init__(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'): - email = etree.SubElement(author, 'url') - email.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'): - email = etree.SubElement(contrib, 'url') - email.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. - :type pretty: bool - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :type extensions: bool - :param encoding: Encoding used in the XML file (default: UTF-8). - :type encoding: str - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :type xml_declaration: bool - :returns: String representation of the ATOM feed. - ''' - 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. - :type filename: str, fd - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :type extensions: bool - :param pretty: If the feed should be split into multiple lines and - properly indented. - :type pretty: bool - :param encoding: Encoding used in the XML file (default: UTF-8). - :type encoding: str - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :type xml_declaration: bool - ''' - 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. @@ -253,8 +70,7 @@ def _create_rss(self, extensions=True): nsmap = dict() if extensions: for ext in self.__extensions.values() or []: - if ext.get('rss'): - nsmap.update( ext['inst'].extend_ns() ) + nsmap.update( ext['inst'].extend_ns() ) nsmap.update({'atom': 'http://www.w3.org/2005/Atom', 'content': 'http://purl.org/rss/1.0/modules/content/'}) @@ -272,21 +88,6 @@ def _create_rss(self, extensions=True): 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') @@ -437,24 +238,8 @@ def title(self, title=None): :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 + return self.__rss_title def updated(self, updated=None): @@ -474,6 +259,7 @@ def updated(self, updated=None): :type updated: str or datetime.datetime :returns: Modification date as datetime.datetime ''' + # TODO: Standardize on one way to set publication date if not updated is None: if isinstance(updated, string_types): updated = dateutil.parser.parse(updated) @@ -481,10 +267,9 @@ def updated(self, updated=None): 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 + return self.__rss_lastBuildDate def lastBuildDate(self, lastBuildDate=None): @@ -544,85 +329,27 @@ def author(self, author=None, replace=False, **kwargs): 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 + if replace or self.__rss_author is None: + self.__rss_author = [] + self.__rss_author += ensure_format( author, + set(['name', 'email']), set(['name', 'email'])) + return self.__rss_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. + def link(self, href=None): + '''Get or set the feed's link (website). - 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. If multiple links are given, the last one will be used. - - :param link: Dict or list of dicts with data. - :param replace: Add or replace old data. + :param href: URI of this feed's website. Example:: - >>> feedgen.link( href='http://example.com/', rel='self') + >>> feedgen.link( href='http://example.com/') [{'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 + if not href is None: + self.__rss_link = href + return self.__rss_link def category(self, category=None, replace=False, **kwargs): @@ -638,35 +365,28 @@ def category(self, category=None, replace=False, **kwargs): - *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 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 = {} + if replace or self.__rss_category is None: + self.__rss_category = [] + if isinstance(category, collections.Mapping): + category = [category] + for cat in category: + rss_cat = dict() 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 + return self.__rss_category def cloud(self, domain=None, port=None, path=None, registerProcedure=None, @@ -688,87 +408,26 @@ def cloud(self, domain=None, port=None, path=None, registerProcedure=None, 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. + generate the feed, for debugging and other purposes. :param generator: Software used to create the feed. - :param version: Version of the software. - :param uri: URI the software can be found. + :param version: (Optional) Version of the software. + :param uri: (Optional) 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 + \ (("/" + str(version)) if version is not None else "") + \ ((" " + uri) if uri else "") - 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 + return self.__rss_generator 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. + '''Set the image of the feed. + + Don't confuse with itunes:image. :param url: The URL of a GIF, JPEG or PNG image. :param title: Describes the image. The default value is the feeds title. @@ -789,62 +448,37 @@ def image(self, url=None, title=None, link=None, width=None, height=None, 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. + '''Get or set the copyright notice for content in the channel. :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 + if not copyright is None: + self.__rss_copyright = copyright + return self.__rss_copyright def description(self, description=None): - '''Set and get the description of the feed. This is an RSS only element + '''Set and get the description of the feed, 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. + RSS feeds. :param description: Description of the channel. :returns: Description of the channel. ''' - return self.subtitle( description ) + if not description is None: + self.__rss_description = description + return self.__rss_description def docs(self, docs=None): - '''Get or set the docs value of the feed. This is an RSS only value. It + '''Get or set the docs value of the feed. 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 @@ -863,9 +497,7 @@ def docs(self, docs=None): 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. + language sites, for example, on a single page. The value should be an IETF language tag. :param language: Language of the feed. @@ -873,13 +505,12 @@ def language(self, language=None): ''' 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. + person responsible for editorial content. :param managingEditor: Email adress of the managing editor. :returns: Email adress of the managing editor. @@ -899,11 +530,10 @@ def pubDate(self, pubDate=None): 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 ''' + # TODO Add/rename to lastBuildDate if not pubDate is None: if isinstance(pubDate, string_types): pubDate = dateutil.parser.parse(pubDate) @@ -917,8 +547,7 @@ def pubDate(self, pubDate=None): def rating(self, rating=None): - '''Set and get the PICS rating for the channel. It is an RSS only - value. + '''Set and get the PICS rating for the channel. ''' if not rating is None: self.__rss_rating = rating @@ -927,7 +556,7 @@ def rating(self, rating=None): 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. + which hours they can skip. This method can be called with an hour or a list of hours. The hours are represented as integer values from 0 to 23. @@ -950,7 +579,7 @@ def skipHours(self, hours=None, replace=False): 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. + which days they can skip. 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'. @@ -965,7 +594,7 @@ def skipDays(self, days=None, replace=False): for d in days: if not d in ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']: - raise ValueError('Invalid day %s' % h) + raise ValueError('Invalid day %s' % d) if replace or not self.__rss_skipDays: self.__rss_skipDays = set() self.__rss_skipDays |= set(days) @@ -973,7 +602,7 @@ def skipDays(self, days=None, replace=False): 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 + '''Get or set the value of textInput. 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. @@ -994,7 +623,7 @@ def textInput(self, title=None, description=None, name=None, link=None): def ttl(self, ttl=None): - '''Get or set the ttl value. It is an RSS only element. ttl stands for + '''Get or set the ttl value. 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. @@ -1009,7 +638,6 @@ def ttl(self, ttl=None): 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. @@ -1122,11 +750,10 @@ def remove_item(self, item): self.remove_entry(item) - def load_extension(self, name, atom=True, rss=True): + def load_extension(self, name): '''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 @@ -1142,11 +769,11 @@ def load_extension(self, name, atom=True, rss=True): ext = getattr(extmod, extname) extinst = ext() setattr(self, name, extinst) - self.__extensions[name] = {'inst':extinst,'atom':atom,'rss':rss} + self.__extensions[name] = {'inst':extinst} # Try to load the extension for already existing entries: for entry in self.__feed_entries: try: - entry.load_extension( name, atom, rss ) + entry.load_extension( name) except ImportError: pass diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 40218bc..3d555b5 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -15,23 +15,21 @@ 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.guid('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.guid('http://lernfunk.de/media/654321/1') fe.title('The Second Episode') fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') + fe.guid('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') self.fg = fg @@ -57,7 +55,7 @@ def test_removeEntryByIndex(self): self.title = 'Some Testfeed' fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') + fe.guid('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') assert len(fg.entry()) == 1 fg.remove_entry(0) @@ -69,7 +67,7 @@ def test_removeEntryByEntry(self): self.title = 'Some Testfeed' fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') + fe.guid('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') assert len(fg.entry()) == 1 @@ -79,15 +77,14 @@ def test_removeEntryByEntry(self): def test_categoryHasDomain(self): fg = FeedGenerator() fg.title('some title') - fg.link( href='http://www.dontcare.com', rel='alternate' ) + fg.link( href='http://www.dontcare.com') fg.description('description') fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') + fe.guid('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() diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 4140e18..3f1d5a3 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -17,10 +17,8 @@ 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' @@ -28,19 +26,14 @@ def setUp(self): 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.image = 'http://ex.com/logo.jpg' + self.description = 'This is a cool feed!' 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' @@ -48,9 +41,7 @@ def setUp(self): 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.contributor = {'name':"Contributor Name", 'email': 'Contributor email'} self.copyright = "The copyright notice" self.docs = 'http://www.rssboard.org/rss-specification' self.managingEditor = 'mail@example.com' @@ -67,21 +58,16 @@ def setUp(self): 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.link( href=self.linkHref) + fg.image(self.image) + fg.description(self.description) 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.category(term=self.categoryTerm, scheme=self.categoryScheme) fg.copyright(self.copyright) fg.docs(docs=self.docs) fg.managingEditor(self.managingEditor) @@ -100,122 +86,18 @@ def setUp(self): 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() == self.linkHref - assert fg.link()[1]['href'] == self.link2Href - assert fg.link()[1]['rel'] == self.link2Rel + assert fg.image()['url'] == self.image + assert fg.description() == self.description 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}url" % nsAtom).text == self.contributor['uri'] - assert feed.find("{%s}rights" % nsAtom).text == self.copyright def test_rssFeedFile(self): fg = self.fg @@ -234,28 +116,24 @@ def test_rssFeedString(self): def test_loadPodcastExtension(self): fg = self.fg - fg.load_extension('podcast', atom=True, rss=True) + fg.load_extension('podcast') 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("description").text == self.description 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("url").text == self.image 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("category").text == self.categoryTerm assert channel.find("cloud").get('domain') == self.cloudDomain assert channel.find("cloud").get('port') == self.cloudPort assert channel.find("cloud").get('path') == self.cloudPath diff --git a/readme.md b/readme.md index c7c00df..e1cf556 100644 --- a/readme.md +++ b/readme.md @@ -1,11 +1,12 @@ ============= -Feedgenerator +Feedgenerator (forked) ============= +Ignore: [![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 +This module can be used to generate web feeds in RSS format. It has support for extensions. Included is for example an extension to produce Podcasts. @@ -24,46 +25,25 @@ More details about the project: Installation ------------ -**Prebuild packages** - -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/) - -Simply enable the repository and run: - - $ yum install python-feedgen - -or for the Python 3 package: - - $ yum install python3-feedgen - - -**Using pip** - -You can also use pip to install the feedgen module. Simply run:: - - $ pip install feedgen +Currently, you'll need to clone this repository, and create a virtualenv and +install lxml and dateutils. ------------- 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 >>> 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.image('http://ex.com/logo.jpg') + >>> fg.description('This is a cool feed!') + >>> fg.link( href='http://larskiesow.de/test.atom') >>> fg.language('en') Note that for the methods which set fields that can occur more than once in a @@ -83,11 +63,9 @@ Example:: Generate the Feed ----------------- -After that you can generate both RSS or ATOM by calling the respective method:: +After that you can generate RSS by calling: - >>> 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 @@ -98,10 +76,10 @@ 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.guid('http://lernfunk.de/media/654321/1') >>> fe.title('The First Episode') The FeedGenerators method `add_entry(...)` without argument provides will @@ -115,24 +93,18 @@ 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') 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 +is required to have at least the method `extend_rss(...)`. 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 +`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. - **Example: Producing a Podcast** One extension already provided is the podcast extension. A podcast is an RSS @@ -147,7 +119,7 @@ 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.guid('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') @@ -161,7 +133,7 @@ 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 +Of cause you can still produce a normal 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`. @@ -177,4 +149,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`](https://github.com/tobinus/python-feedgen/blob/podcastgen/feedgen/__main__.py). From ec0745e085c6177ce423266d1cdc30c918e2c46f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 22 Jun 2016 21:34:53 +0200 Subject: [PATCH 011/200] Integrate podcast extension into the core All podcast features are now available by default, without needing to load the podcast extension. This is done because this fork is meant to generate podcasts, first and foremost. A few remaining traces of the dc extension, forgotten in commit ad62f1cc3d9, was also removed from the documentation. A mishap in the parameter type description for itunes_complete and itunes_new_feed_url, introduced in commit 71810044bdd, was also fixed. --- doc/api.rst | 3 - doc/ext/api.ext.podcast.rst | 7 - doc/ext/api.ext.podcast_entry.rst | 7 - feedgen/__init__.py | 113 +++++++---- feedgen/__main__.py | 19 +- feedgen/entry.py | 218 +++++++++++++++++++++ feedgen/ext/podcast.py | 316 ------------------------------ feedgen/ext/podcast_entry.py | 255 ------------------------ feedgen/feed.py | 296 +++++++++++++++++++++++++++- feedgen/tests/test_feed.py | 3 - readme.md | 31 ++- 11 files changed, 600 insertions(+), 668 deletions(-) delete mode 100644 doc/ext/api.ext.podcast.rst delete mode 100644 doc/ext/api.ext.podcast_entry.rst delete mode 100644 feedgen/ext/podcast.py delete mode 100644 feedgen/ext/podcast_entry.py diff --git a/doc/api.rst b/doc/api.rst index 6aca66e..fd9f17d 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -14,6 +14,3 @@ 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.podcast.rst b/doc/ext/api.ext.podcast.rst deleted file mode 100644 index 0fdcb0c..0000000 --- a/doc/ext/api.ext.podcast.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. raw:: html - -
Contents
-
- -.. automodule:: feedgen.ext.podcast - :members: diff --git a/doc/ext/api.ext.podcast_entry.rst b/doc/ext/api.ext.podcast_entry.rst deleted file mode 100644 index 2ee1e0f..0000000 --- a/doc/ext/api.ext.podcast_entry.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. raw:: html - -
Contents
-
- -.. automodule:: feedgen.ext.podcast_entry - :members: diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 80f061a..1995560 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -1,14 +1,34 @@ # -*- coding: utf-8 -*- """ - ======= - feedgen (forked) - ======= + ============= + Feedgenerator (forked) + ============= - This module can be used to generate podcast feeds in RSS format. - It has support for extensions. + Ignore: + [![Build Status](https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master) + ](https://travis-ci.org/lkiesow/python-feedgen) - :copyright: 2013 by Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + This module can be used to generate web feeds in 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 + 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/ + - Python Package Index: https://pypi.python.org/pypi/feedgen/ + + + ------------ + Installation + ------------ + + Currently, you'll need to clone this repository, and create a virtualenv and + install lxml and dateutils. ------------- @@ -28,8 +48,8 @@ >>> fg.link( href='http://larskiesow.de/test.atom') >>> 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 @@ -55,19 +75,18 @@ 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.guid('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 @@ -78,43 +97,48 @@ >>> fg.load_extension('someext') - 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 one method, - `extend_rss(...)`. 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 method `extend_rss(...)`. 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. - **Example: Producing a Podcast** + 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 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`. - One extension already provided is the podcast extension. A podcast is an RSS - feed with some additional elements for ITunes. + **Example: Producing a Podcast** - To produce a podcast simply load the `podcast` extension:: + NOTE: The podcast extension is currently baked in, and can thus be used without loading any + extensions. This will therefore not be an example on how to use an extension, but rather how + to use the podcast features:: >>> from feedgen.feed import FeedGenerator >>> fg = FeedGenerator() - >>> fg.load_extension('podcast') ... - >>> fg.podcast.itunes_category('Technology', 'Podcasting') + >>> fg.itunes_category('Technology', 'Podcasting') + ... + >>> fe = fg.add_entry() + >>> fe.guid('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 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 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 @@ -124,4 +148,9 @@ $ 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 + [`__main__.py`](https://github.com/tobinus/python-feedgen/blob/podcastgen/feedgen/__main__.py). + + """ diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 090d3e9..778963c 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -62,19 +62,16 @@ def print_enc(s): if 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, ' + \ + fg.itunes_author('Lars Kiesow') + fg.itunes_category('Technology', 'Podcasting') + fg.itunes_explicit('no') + fg.itunes_complete('no') + fg.itunes_new_feed_url('http://example.com/new-feed.rss') + fg.itunes_owner('John Doe', 'john@example.com') + fg.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') + fe.itunes_author('Lars Kiesow') print_enc (fg.rss_str(pretty=True)) elif arg.endswith('rss'): fg.rss_file(arg) diff --git a/feedgen/entry.py b/feedgen/entry.py index a0d73e8..5e15356 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -36,6 +36,18 @@ def __init__(self): self.__rss_source = None self.__rss_title = None + # 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 + # Extension list: self.__extensions = {} @@ -88,6 +100,45 @@ def rss_entry(self, extensions=True): pubDate = etree.SubElement(entry, 'pubDate') pubDate.text = formatRFC2822(self.__rss_pubDate) + # Itunes fields + 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 + if extensions: for ext in self.__extensions.values() or []: ext['inst'].extend_rss(entry) @@ -291,6 +342,173 @@ def enclosure(self, url=None, length=None, type=None): self.__rss_enclosure = {'url': url, 'length': length, 'type': type} return self.__rss_enclosure + 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. + :type itunes_author: str + :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. Note that the episode can still be + found by inspecting the XML, thus it is public. + + :param itunes_block: Block podcast episodes. + :type itunes_block: bool + :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. + :type itunes_image: str + :returns: Image of the podcast. + ''' + if not itunes_image is None: + lowercase_itunes_image = itunes_image.lower() + if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): + raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) + 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. + :type itunes_duration: str or int + :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 "%s"' % itunes_duration) + 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts + as blank. + :type itunes_explicit: str + :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 "%s" for explicit tag' % itunes_explicit) + 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 itunes_is_closed_captioned: If the episode has closed captioning support. + :type itunes_is_closed_captioned: bool or str + :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. + :type itunes_order: int + :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. + :type itunes_subtitle: str + :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. + :type itunes_summary: str + :returns: Summary of the podcast episode. + ''' + if not itunes_summary is None: + self.__itunes_summary = itunes_summary + return self.__itunes_summary def load_extension(self, name): '''Load a specific extension by name. diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py deleted file mode 100644 index 61bf4b6..0000000 --- a/feedgen/ext/podcast.py +++ /dev/null @@ -1,316 +0,0 @@ -# -*- coding: utf-8 -*- -''' - feedgen.ext.podcast - ~~~~~~~~~~~~~~~~~~~ - - Extends the FeedGenerator to produce podcasts. - - :copyright: 2013, Lars Kiesow - - :license: FreeBSD and LGPL, see license.* for more details. -''' - -from lxml import etree -from feedgen.ext.base import BaseExtension - - -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' - - 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'): - subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) - subcategory.attrib['text'] = self.__itunes_category['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. - :type itunes_author: str - :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, itunes_subcategory=None): - '''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, unescaped. - :type itunes_category: str - :param itunes_subcategory: Subcategory of the podcast, unescaped. The subcategory need not be set. - :type itunes_subcategory: str - :returns: Dictionary which has category with key 'cat', and optionally subcategory with key 'sub'. - ''' - if not itunes_category is None: - if not itunes_category in self._itunes_categories.keys(): - raise ValueError('Invalid category %s' % itunes_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 "%s" under category "%s"' - % (itunes_subcategory, itunes_category)) - cat['sub'] = itunes_subcategory - self.__itunes_category = 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. - :type itunes_image: str - :returns: Image of the podcast. - ''' - if not itunes_image is None: - lowercase_itunes_image = itunes_image.lower() - if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) - 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts - as blank. - :type itunes_explicit: str - :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 "%s" for explicit tag' % itunes_explicit) - 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. - :type itunes_complete: bool - :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 "%s" for complete tag' % itunes_complete) - 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. - :type itunes_new_feed_url: bool - :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. - - Both the name and email are required; you cannot use one or the other alone. - - :param name: The name of the owner of the feed. - :type name: str - :param email: The feed owner's email. - :type email: str - :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. - :type itunes_subtitle: str - :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. - :type itunes_summary: str - :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': [] - } diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py deleted file mode 100644 index ce1a85d..0000000 --- a/feedgen/ext/podcast_entry.py +++ /dev/null @@ -1,255 +0,0 @@ -# -*- coding: utf-8 -*- -''' - feedgen.ext.podcast_entry - ~~~~~~~~~~~~~~~~~~~~~~~~~ - - Extends the feedgen to produce podcasts. - - :copyright: 2013, Lars Kiesow - - :license: FreeBSD and LGPL, see license.* for more details. -''' - -from lxml import etree -from feedgen.ext.base import BaseEntryExtension - - -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. - :type itunes_author: str - :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. Note that the episode can still be - found by inspecting the XML, thus it is public. - - :param itunes_block: Block podcast episodes. - :type itunes_block: bool - :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. - :type itunes_image: str - :returns: Image of the podcast. - ''' - if not itunes_image is None: - lowercase_itunes_image = itunes_image.lower() - if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) - 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. - :type itunes_duration: str or int - :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 "%s"' % itunes_duration) - 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts - as blank. - :type itunes_explicit: str - :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 "%s" for explicit tag' % itunes_explicit) - 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 itunes_is_closed_captioned: If the episode has closed captioning support. - :type itunes_is_closed_captioned: bool or str - :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. - :type itunes_order: int - :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. - :type itunes_subtitle: str - :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. - :type itunes_summary: str - :returns: Summary of the podcast episode. - ''' - if not itunes_summary is None: - self.__itunes_summary = itunes_summary - return self.__itunes_summary diff --git a/feedgen/feed.py b/feedgen/feed.py index da7a36b..e2c4713 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -57,8 +57,18 @@ def __init__(self): self.__rss_ttl = None self.__rss_webMaster = None - # Extension list: - __extensions = {} + ## 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 @@ -67,13 +77,14 @@ def _create_rss(self, extensions=True): :returns: Tuple containing the feed root element and the element tree. ''' - nsmap = dict() - if extensions: - for ext in self.__extensions.values() or []: - nsmap.update( ext['inst'].extend_ns() ) - nsmap.update({'atom': 'http://www.w3.org/2005/Atom', - 'content': 'http://purl.org/rss/1.0/modules/content/'}) + nsmap = { + 'atom': 'http://www.w3.org/2005/Atom', + 'content': 'http://purl.org/rss/1.0/modules/content/', + 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd', + } + + ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' feed = etree.Element('rss', version='2.0', nsmap=nsmap ) channel = etree.SubElement(feed, 'channel') @@ -169,6 +180,52 @@ def _create_rss(self, extensions=True): webMaster = etree.SubElement(channel, 'webMaster') webMaster.text = self.__rss_webMaster + 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' + + 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'): + subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) + subcategory.attrib['text'] = self.__itunes_category['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 + if extensions: for ext in self.__extensions.values() or []: if ext.get('rss'): @@ -646,6 +703,229 @@ def webMaster(self, webMaster=None): self.__rss_webMaster = webMaster return self.__rss_webMaster + 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. + :type itunes_author: str + :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, itunes_subcategory=None): + '''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, unescaped. + :type itunes_category: str + :param itunes_subcategory: Subcategory of the podcast, unescaped. The subcategory need not be set. + :type itunes_subcategory: str + :returns: Dictionary which has category with key 'cat', and optionally subcategory with key 'sub'. + ''' + if not itunes_category is None: + if not itunes_category in self._itunes_categories.keys(): + raise ValueError('Invalid category %s' % itunes_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 "%s" under category "%s"' + % (itunes_subcategory, itunes_category)) + cat['sub'] = itunes_subcategory + self.__itunes_category = 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. + :type itunes_image: str + :returns: Image of the podcast. + ''' + if not itunes_image is None: + lowercase_itunes_image = itunes_image.lower() + if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): + raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) + 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts + as blank. + :type itunes_explicit: str + :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 "%s" for explicit tag' % itunes_explicit) + 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 <itunes:complete> 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. + :type itunes_complete: bool or str + :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 "%s" for complete tag' % itunes_complete) + 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. + :type itunes_new_feed_url: str + :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. + + Both the name and email are required; you cannot use one or the other alone. + + :param name: The name of the owner of the feed. + :type name: str + :param email: The feed owner's email. + :type email: str + :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. + :type itunes_subtitle: str + :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. + :type itunes_summary: str + :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': [] + } def add_entry(self, feedEntry=None): '''This method will add a new entry to the feed. If the feedEntry diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 3f1d5a3..941d835 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -114,9 +114,6 @@ def test_rssFeedString(self): rssString = fg.rss_str(pretty=True, xml_declaration=False) self.checkRssString(rssString) - def test_loadPodcastExtension(self): - fg = self.fg - fg.load_extension('podcast') def checkRssString(self, rssString): diff --git a/readme.md b/readme.md index e1cf556..40439f0 100644 --- a/readme.md +++ b/readme.md @@ -105,18 +105,27 @@ is required to have at least the method `extend_rss(...)`. Although not required either in the same file as SomextExtension or in `ext/someext_entry.py` which is suggested especially for large extensions. -**Example: Producing a Podcast** +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 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`. -One extension already provided is the podcast extension. A podcast is an RSS -feed with some additional elements for ITunes. +**Example: Producing a Podcast** -To produce a podcast simply load the `podcast` extension:: +NOTE: The podcast extension is currently baked in, and can thus be used without loading any +extensions. This will therefore not be an example on how to use an extension, but rather how +to use the podcast features. >>> from feedgen.feed import FeedGenerator >>> fg = FeedGenerator() - >>> fg.load_extension('podcast') ... - >>> fg.podcast.itunes_category('Technology', 'Podcasting') + >>> fg.itunes_category('Technology', 'Podcasting') ... >>> fe = fg.add_entry() >>> fe.guid('http://lernfunk.de/media/654321/1/file.mp3') @@ -127,16 +136,6 @@ 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. - -Of cause you can still produce a normal 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`. --------------------- From b41bfea975f205e53b596992ee714baaf0375bfe Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 22 Jun 2016 22:16:37 +0200 Subject: [PATCH 012/200] Remove support for extensions The need for extensions to the podcast format is pretty low, since the podcasting standard probably won't change much in the future. Instead, if you want functionality which is missing from the core classes, you are encouraged to create a subclass which extends the appropriate class, and overrides methods for feed generation and initialization, and adds getters and setters. I plan on making this much easier by doing some refactoring; a substantial amount of boilerplate is needed at the moment if you wish to extend it. --- doc/api.rst | 1 - doc/ext/api.ext.base.rst | 7 ----- feedgen/__init__.py | 45 +++++----------------------- feedgen/entry.py | 35 ---------------------- feedgen/ext/__init__.py | 6 ---- feedgen/ext/base.py | 33 --------------------- feedgen/feed.py | 64 ++++------------------------------------ readme.md | 45 +++++----------------------- 8 files changed, 19 insertions(+), 217 deletions(-) delete mode 100644 doc/ext/api.ext.base.rst delete mode 100644 feedgen/ext/__init__.py delete mode 100644 feedgen/ext/base.py diff --git a/doc/api.rst b/doc/api.rst index fd9f17d..51ade62 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -13,4 +13,3 @@ Contents: api.feed api.entry api.util - ext/api.ext.base diff --git a/doc/ext/api.ext.base.rst b/doc/ext/api.ext.base.rst deleted file mode 100644 index 0d6573c..0000000 --- a/doc/ext/api.ext.base.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. raw:: html - -
Contents
-
- -.. automodule:: feedgen.ext.base - :members: diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 1995560..9f41bfb 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -8,9 +8,7 @@ [![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 RSS format. It - has support for extensions. Included is for example an extension to produce - Podcasts. + This module can be used to generate podcast feeds in RSS format. 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 @@ -88,41 +86,12 @@ 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 - ---------- + -------------------------- + Using the podcast features + -------------------------- - 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') - - 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 method `extend_rss(...)`. 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. - - 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 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** - - NOTE: The podcast extension is currently baked in, and can thus be used without loading any - extensions. This will therefore not be an example on how to use an extension, but rather how - to use the podcast features:: + All iTunes-specific features are available as methods that start with `itunes_`, + although most features are platform agnostic:: >>> from feedgen.feed import FeedGenerator >>> fg = FeedGenerator() @@ -144,7 +113,7 @@ Testing the Generator --------------------- - You can test the module by simply executing:: + You can test the module integration-testing-style by simply executing:: $ python -m feedgen diff --git a/feedgen/entry.py b/feedgen/entry.py index 5e15356..3dc7524 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -48,9 +48,6 @@ def __init__(self): self.__itunes_subtitle = None self.__itunes_summary = None - # Extension list: - self.__extensions = {} - def rss_entry(self, extensions=True): '''Create a RSS item and return it.''' @@ -139,10 +136,6 @@ def rss_entry(self, extensions=True): summary = etree.SubElement(entry, '{%s}summary' % ITUNES_NS) summary.text = self.__itunes_summary - if extensions: - for ext in self.__extensions.values() or []: - ext['inst'].extend_rss(entry) - return entry @@ -509,31 +502,3 @@ def itunes_summary(self, itunes_summary=None): if not itunes_summary is None: self.__itunes_summary = itunes_summary return self.__itunes_summary - - def load_extension(self, name): - '''Load a specific extension by name. - - :param name: Name of the extension to load. - ''' - # 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 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 - 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} diff --git a/feedgen/ext/__init__.py b/feedgen/ext/__init__.py deleted file mode 100644 index 0e2b628..0000000 --- a/feedgen/ext/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- -""" - =========== - feedgen.ext - =========== -""" diff --git a/feedgen/ext/base.py b/feedgen/ext/base.py deleted file mode 100644 index 0c8555f..0000000 --- a/feedgen/ext/base.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -''' - feedgen.ext.base - ~~~~~~~~~~~~~~~~ - - Basic FeedGenerator extension which does nothing but provides all necessary - methods. - - :copyright: 2013, Lars Kiesow - - :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() - - 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 - - -class BaseEntryExtension(BaseExtension): - '''Basic FeedEntry extension. - ''' diff --git a/feedgen/feed.py b/feedgen/feed.py index e2c4713..b85870a 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -30,7 +30,6 @@ class FeedGenerator(object): def __init__(self): - self.__extensions = {} self.__feed_entries = [] ## RSS @@ -71,8 +70,7 @@ def __init__(self): self.__itunes_summary = None - - def _create_rss(self, extensions=True): + def _create_rss(self): '''Create an RSS feed xml structure containing all previously set fields. :returns: Tuple containing the feed root element and the element tree. @@ -226,11 +224,6 @@ def _create_rss(self, extensions=True): summary = etree.SubElement(channel, '{%s}summary' % ITUNES_NS) summary.text = self.__itunes_summary - 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) @@ -239,16 +232,13 @@ def _create_rss(self, extensions=True): return feed, doc - def rss_str(self, pretty=False, extensions=True, encoding='UTF-8', + def rss_str(self, pretty=False, 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. :type pretty: bool - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :type extensions: bool :param encoding: Encoding used in the XML file (default: UTF-8). :type encoding: str :param xml_declaration: If an XML declaration should be added to the @@ -256,20 +246,17 @@ def rss_str(self, pretty=False, extensions=True, encoding='UTF-8', :type xml_declaration: bool :returns: String representation of the RSS feed. ''' - feed, doc = self._create_rss(extensions=extensions) + feed, doc = self._create_rss() return etree.tostring(feed, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) - def rss_file(self, filename, extensions=True, pretty=False, + def rss_file(self, filename, 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. :type filename: str or fd - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :type extensions: bool :param pretty: If the feed should be split into multiple lines and properly indented. :type pretty: bool @@ -279,7 +266,7 @@ def rss_file(self, filename, extensions=True, pretty=False, output (Default: enabled). :type xml_declaration: bool ''' - feed, doc = self._create_rss(extensions=extensions) + feed, doc = self._create_rss() doc.write(filename, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) @@ -947,18 +934,6 @@ def add_entry(self, feedEntry=None): 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.load_extension( extname, ext['atom'], ext['rss'] ) - except ImportError: - pass - self.__feed_entries.append( feedEntry ) return feedEntry @@ -1028,32 +1003,3 @@ def remove_item(self, item): remove_entry. ''' self.remove_entry(item) - - - def load_extension(self, name): - '''Load a specific extension by name. - - :param name: Name of the extension to load. - :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:] + '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} - - # Try to load the extension for already existing entries: - for entry in self.__feed_entries: - try: - entry.load_extension( name) - except ImportError: - pass diff --git a/readme.md b/readme.md index 40439f0..dd7329d 100644 --- a/readme.md +++ b/readme.md @@ -6,9 +6,7 @@ Ignore: [![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 RSS format. It -has support for extensions. Included is for example an extension to produce -Podcasts. +This module can be used to generate podcast feeds in RSS format. 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 @@ -86,41 +84,12 @@ 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 ----------- +-------------------------- +Using the podcast features +-------------------------- -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') - -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 method `extend_rss(...)`. 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. - -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 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** - -NOTE: The podcast extension is currently baked in, and can thus be used without loading any -extensions. This will therefore not be an example on how to use an extension, but rather how -to use the podcast features. +Most iTunes-specific features are available as methods that start with `itunes_`, +although most features are platform agnostic. >>> from feedgen.feed import FeedGenerator >>> fg = FeedGenerator() @@ -142,7 +111,7 @@ to use the podcast features. Testing the Generator --------------------- -You can test the module by simply executing:: +You can test the module integration-testing-style by simply executing:: $ python -m feedgen From 3e42f08f6cdfa02878a6c5715d4c09038195e2c3 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 22 Jun 2016 23:22:31 +0200 Subject: [PATCH 013/200] Explain the reasoning behind this fork --- readme.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index dd7329d..89746ef 100644 --- a/readme.md +++ b/readme.md @@ -1,11 +1,13 @@ ============= -Feedgenerator (forked) +Feedgenerator (forked) - Podcasting for humans ============= Ignore: [![Build Status](https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master) ](https://travis-ci.org/lkiesow/python-feedgen) +**Note: This document is in the process of being rewritten.** + This module can be used to generate podcast feeds in RSS format. It is licensed under the terms of both, the FreeBSD license and the LGPLv3+. @@ -118,3 +120,68 @@ You can test the module integration-testing-style 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/tobinus/python-feedgen/blob/podcastgen/feedgen/__main__.py). + + +---------- +Philosophy +---------- + +This project is heavily inspired by the wonderful +[Kenneth Reitz](http://www.kennethreitz.org/projects), known for the +[Requests](http://docs.python-requests.org) library, which features an API which is +as beautiful as it is effective. Watching his +["Documentation is King" talk](http://www.kennethreitz.org/talks/#/documentation-is-king/), +I wanted to make some of the libraries I'm using suitable for use by actual humans. + +This project is to be developed following the same +[PEP 20](https://www.python.org/dev/peps/pep-0020/) idioms as +[Requests](http://docs.python-requests.org/en/master/user/intro/#philosophy): + +1. Beautiful is better than ugly. +2. Explicit is better than implicit. +3. Simple is better than complex. +4. Complex is better than complicated. +5. Readability counts. + +To enable this, the project focuses on one task alone: making it easy to generate a podcast. + + +------------- +Why the fork? +------------- + +This project is a fork of `python-feedgen` which cuts away everything that +doesn't serve the goal of **making it easy and simple to generate podcasts** from +a Python program. **Thus, this project includes only a subset of the features +of `python-feedgen`**. And I don't think anyone in their right mind would accept a pull +request which removes 70% of the features ;-) Among other things, support for ATOM and +Dublin Core is removed, and the remaining code is almost entirely rewritten. + +The reason I felt like making such drastic changes, is that the original library is +**exceptionally hard to learn** and use. Error messages would not tell you what was wrong, +the concept of extensions is poorly explained and the methods are a bit weird, in that +they function as getters and setters at the same time. The fact that you have three +separate ways to go about setting multi-value variables, is also a bit confusing. + +Perhaps the biggest problem, though, is the awkwardness that stems from enabling +RSS and ATOM feeds through the same API. Some methods will map an ATOM value to +its closest sibling in RSS, some in logical ways (like the ATOM method `rights` setting +the value of the RSS property `copyright`) and some differ in subtle ways (like using +(ATOM) `logo` versus (RSS) `image`). Other methods are more complex (see `link`). They're all +confusing, though, since changing one property automatically changes another implicitly. +Removing ATOM (or RSS for that matter) fixes all these issues. + +Even then, the RSS specs and iTunes' podcast recommendations are sometimes able to +cause confusion on their own, especially for those of us who don't have time to +read both specifications from start to end. For example, the original RSS spec +includes support for an image, but that image is required to be less than 144 pixels +wide (88 pixels being the default) and 400 pixels high (remember, this was year _2000_). +Itunes can't have any of that (understandably so), so they added their own `itunes:image` +tag, which has its own set of requirements (images can be no smaller than 1400x1400px!). +I believe **the API should help guide the users** by hiding the legacy image tag, +and gently push users towards the iTunes tag used today. + +In short, the **original project breaks all the idioms listed in Philosophy**, and +fixing it would require changes too big or too dramatic to be applied upstream. +Whenever a change _is_ appropriate for upstream, however, we should strive to +bring it there, so it can benefit **everyone**. From f25d24fc1a80d9c1196e6a37810b11b3cf291110 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 23 Jun 2016 11:23:45 +0200 Subject: [PATCH 014/200] Move _itunes_category close to where it's used --- feedgen/feed.py | 59 +++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index b85870a..e115bc7 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -740,6 +740,36 @@ def itunes_category(self, itunes_category=None, itunes_subcategory=None): self.__itunes_category = cat return self.__itunes_category + _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': [] + } + 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 @@ -884,35 +914,6 @@ def itunes_summary(self, itunes_summary=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': [] - } def add_entry(self, feedEntry=None): '''This method will add a new entry to the feed. If the feedEntry From 72172a3b66ea162c0a40618dd48f89bdbb734fbc Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 23 Jun 2016 14:09:12 +0200 Subject: [PATCH 015/200] Remove little used feed properties The following properties were removed: * ttl: its meaning is disputed, and HTTP cache mechanics can be used in its place. Basically, no one knows how to use this. * author: As far as I could see, this property was not actually in use, it was just tricking us into thinking it was. * category: I don't think many podcast clients implement this, and I was unable to find a universally used domain. itunes:category is where it's at. * image: The RSS image must be under 144x400 pixels in size. Obvously horribly outdated, and superceded by itunes:image. * itunes:summary: Exact same use as description. No reason to duplicate data; cannot imagine why anyone would want a different description on iTunes than on other clients. Currently, the element is still present, it's just the same as description. It will be omitted in the future. * rating: This uses PICS ratings, which are superceded by POWDER, which W3 suggests should be pointed to in the HTTP headers, not in the content of a document. Plus, this tag supports only PICS. The itunes:explicit tag fulfills its purpose. * textInput: Quote from the RSS spec: "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." I couldn't have explained it better myself. --- feedgen/__main__.py | 13 +-- feedgen/feed.py | 220 +------------------------------------ feedgen/tests/test_feed.py | 36 +----- 3 files changed, 11 insertions(+), 258 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 778963c..d02a8be 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -39,10 +39,8 @@ def print_enc(s): fg = FeedGenerator() fg.title('Testfeed') - fg.author( {'name':'Lars Kiesow','email':'lkiesow@uos.de'} ) - fg.link( href='http://example.com') - fg.category(term='test') - fg.image('http://ex.com/logo.jpg') + fg.managingEditor('lkiesow@uos.de (Lars Kiesow)') + fg.link(href='http://example.com') fg.copyright('cc-by') fg.description('This is a cool feed!') fg.language('de') @@ -60,7 +58,7 @@ def print_enc(s): fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) if arg == 'rss': - print_enc (fg.rss_str(pretty=True)) + print_enc(fg.rss_str(pretty=True)) elif arg == 'podcast': fg.itunes_author('Lars Kiesow') fg.itunes_category('Technology', 'Podcasting') @@ -68,10 +66,7 @@ def print_enc(s): fg.itunes_complete('no') fg.itunes_new_feed_url('http://example.com/new-feed.rss') fg.itunes_owner('John Doe', 'john@example.com') - fg.itunes_summary('Lorem ipsum dolor sit amet, ' + \ - 'consectetur adipiscing elit. ' + \ - 'Verba tu fingas et ea dicas, quae non sentias?') fe.itunes_author('Lars Kiesow') - print_enc (fg.rss_str(pretty=True)) + print_enc(fg.rss_str(pretty=True)) elif arg.endswith('rss'): fg.rss_file(arg) diff --git a/feedgen/feed.py b/feedgen/feed.py index e115bc7..e307a2e 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -34,26 +34,22 @@ def __init__(self): ## RSS # http://www.rssboard.org/rss-specification + # Mandatory: self.__rss_title = None self.__rss_link = None self.__rss_description = None - self.__rss_author = None - self.__rss_category = None + # Optional: 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 ## ITunes tags @@ -67,7 +63,6 @@ def __init__(self): self.__itunes_new_feed_url = None self.__itunes_owner = None self.__itunes_subtitle = None - self.__itunes_summary = None def _create_rss(self): @@ -97,12 +92,9 @@ def _create_rss(self): link.text = self.__rss_link desc = etree.SubElement(channel, 'description') desc.text = self.__rss_description - 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'] + + summary = etree.SubElement(channel, '{%s}summary' % ITUNES_NS) + summary.text = self.__rss_description if self.__rss_cloud: cloud = etree.SubElement(channel, 'cloud') cloud.attrib['domain'] = self.__rss_cloud.get('domain') @@ -120,25 +112,6 @@ def _create_rss(self): 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 @@ -152,9 +125,6 @@ def _create_rss(self): 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: @@ -165,15 +135,6 @@ def _create_rss(self): 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 @@ -220,10 +181,6 @@ def _create_rss(self): 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 - for entry in self.__feed_entries: item = entry.rss_entry() channel.append(item) @@ -336,50 +293,6 @@ def lastBuildDate(self, lastBuildDate=None): 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.__rss_author is None: - self.__rss_author = [] - self.__rss_author += ensure_format( author, - set(['name', 'email']), set(['name', 'email'])) - return self.__rss_author - - def link(self, href=None): '''Get or set the feed's link (website). @@ -396,43 +309,6 @@ def link(self, href=None): return self.__rss_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. - - 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.__rss_category is None: - self.__rss_category = [] - if isinstance(category, collections.Mapping): - category = [category] - for cat in category: - rss_cat = dict() - 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.__rss_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 @@ -467,34 +343,6 @@ def generator(self, generator=None, version=None, uri=None): return self.__rss_generator - def image(self, url=None, title=None, link=None, width=None, height=None, - description=None): - '''Set the image of the feed. - - Don't confuse with itunes:image. - - :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 - return self.__rss_image - - def copyright(self, copyright=None): '''Get or set the copyright notice for content in the channel. @@ -590,14 +438,6 @@ def pubDate(self, pubDate=None): return self.__rss_pubDate - def rating(self, rating=None): - '''Set and get the PICS rating for the channel. - ''' - 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. @@ -645,40 +485,6 @@ def skipDays(self, days=None, replace=False): return self.__rss_skipDays - def textInput(self, title=None, description=None, name=None, link=None): - '''Get or set the value of textInput. 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. 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. @@ -898,22 +704,6 @@ def itunes_subtitle(self, itunes_subtitle=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. - :type itunes_summary: str - :returns: Summary of the podcast. - ''' - if not itunes_summary is None: - self.__itunes_summary = itunes_summary - return self.__itunes_summary - def add_entry(self, feedEntry=None): '''This method will add a new entry to the feed. If the feedEntry diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 941d835..a396b77 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -23,18 +23,12 @@ def setUp(self): self.authorName = 'John Doe' self.authorMail = 'john@example.de' - self.author = {'name': self.authorName,'email': self.authorMail} self.linkHref = 'http://example.com' - - self.image = 'http://ex.com/logo.jpg' self.description = 'This is a cool feed!' self.language = 'en' - self.categoryTerm = 'This category term' - self.categoryScheme = 'This category scheme' - self.cloudDomain = 'example.com' self.cloudPort = '4711' self.cloudPath = '/ws/example' @@ -45,39 +39,23 @@ def setUp(self): 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.title(self.title) - fg.author(self.author) fg.link( href=self.linkHref) - fg.image(self.image) fg.description(self.description) fg.language(self.language) fg.cloud(domain=self.cloudDomain, port=self.cloudPort, path=self.cloudPath, registerProcedure=self.cloudRegisterProcedure, protocol=self.cloudProtocol) - fg.category(term=self.categoryTerm, scheme=self.categoryScheme) 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 @@ -88,12 +66,11 @@ def test_baseFeed(self): assert fg.title() == self.title - assert fg.author()[0]['name'] == self.authorName - assert fg.author()[0]['email'] == self.authorMail + assert fg.managingEditor() == self.managingEditor + assert fg.webMaster() == self.webMaster assert fg.link() == self.linkHref - assert fg.image()['url'] == self.image assert fg.description() == self.description assert fg.language() == self.language @@ -128,9 +105,6 @@ def checkRssString(self, rssString): 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.find("image").find("url").text == self.image - assert channel.find("image").find("title").text == self.title - assert channel.find("category").text == self.categoryTerm assert channel.find("cloud").get('domain') == self.cloudDomain assert channel.find("cloud").get('port') == self.cloudPort assert channel.find("cloud").get('path') == self.cloudPath @@ -139,14 +113,8 @@ def checkRssString(self, rssString): 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__': From 18a8b8dae5bd98890e5f5d9071cf9152b04b4dd6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 23 Jun 2016 14:42:52 +0200 Subject: [PATCH 016/200] Rename the core classes and entry.py FeedGenerator was renamed to Podcast, so people can think of it as representing a single podcast. FeedGenerator implies that a single object can be used for generating multiple, different feeds, which is incorrect (except for creating both ATOM and RSS). FeedEntry was renamed to Episode, to match its relationship with Podcast. Entry is the term used in ATOM, which was abolished a few commits ago. Its file was also renamed to item.py, to match the term used in RSS. While it could have been renamed to episode.py, I don't want to confuse people by having two different 'episode' pop up when users try to figure out what to import. --- feedgen/__init__.py | 20 ++++++++++---------- feedgen/__main__.py | 4 ++-- feedgen/feed.py | 24 ++++++++++++------------ feedgen/{entry.py => item.py} | 5 ++--- feedgen/tests/test_entry.py | 10 +++++----- feedgen/tests/test_feed.py | 4 ++-- readme.md | 20 ++++++++++---------- 7 files changed, 43 insertions(+), 44 deletions(-) rename feedgen/{entry.py => item.py} (99%) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 9f41bfb..843fca7 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -33,11 +33,11 @@ Create a Feed ------------- - To create a feed simply instantiate the FeedGenerator class and insert some + To create a feed simply instantiate the Podcast class and insert some data:: - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() + >>> from feedgen.feed import Podcast + >>> fg = Podcast() >>> fg.title('Some Testfeed') >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) >>> fg.link( href='http://example.com', rel='alternate' ) @@ -73,17 +73,17 @@ 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 Episode objects and + append them to the list of entries in the Podcast. The most convenient + way to go is to use the Podcast itself for the instantiation of the + Episode object:: >>> fe = fg.add_entry() >>> fe.guid('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 + automatically generate a new Episode object, append it to the feeds internal list of entries and return it, so that additional data can be added. -------------------------- @@ -93,8 +93,8 @@ All iTunes-specific features are available as methods that start with `itunes_`, although most features are platform agnostic:: - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() + >>> from feedgen.feed import Podcast + >>> fg = Podcast() ... >>> fg.itunes_category('Technology', 'Podcasting') ... diff --git a/feedgen/__main__.py b/feedgen/__main__.py index d02a8be..cedb30c 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -8,7 +8,7 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from feedgen.feed import FeedGenerator +from feedgen.feed import Podcast import sys def print_enc(s): @@ -37,7 +37,7 @@ def print_enc(s): arg = sys.argv[1] - fg = FeedGenerator() + fg = Podcast() fg.title('Testfeed') fg.managingEditor('lkiesow@uos.de (Lars Kiesow)') fg.link(href='http://example.com') diff --git a/feedgen/feed.py b/feedgen/feed.py index e307a2e..189a58e 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -13,7 +13,7 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.entry import FeedEntry +from feedgen.item import Episode from feedgen.util import ensure_format, formatRFC2822 import feedgen.version import sys @@ -24,8 +24,8 @@ _feedgen_version = feedgen.version.version_str -class FeedGenerator(object): - '''FeedGenerator for generating RSS feeds. +class Podcast(object): + '''Class representing one podcast feed. ''' @@ -710,8 +710,8 @@ def add_entry(self, feedEntry=None): 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. + :param feedEntry: Episode object to add. + :returns: Episode object created or passed to this function. Example:: @@ -721,7 +721,7 @@ def add_entry(self, feedEntry=None): ''' if feedEntry is None: - feedEntry = FeedEntry() + feedEntry = Episode() version = sys.version_info[0] @@ -731,7 +731,7 @@ def add_entry(self, feedEntry=None): 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 + omittet a new Episode object is created automatically. This is just another name for add_entry(...) ''' return self.add_entry(item) @@ -739,11 +739,11 @@ def add_item(self, item=None): def entry(self, entry=None, replace=False): '''Get or set feed entries. Use the add_entry() method instead to - automatically create the FeedEntry objects. + automatically create the Episode objects. - This method takes both a single FeedEntry object or a list of objects. + This method takes both a single Episode object or a list of objects. - :param entry: FeedEntry object or list of FeedEntry objects. + :param entry: Episode object or list of Episode objects. :returns: List ob all feed entries. ''' if not entry is None: @@ -779,11 +779,11 @@ def item(self, item=None, replace=False): 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. + Episode object to remove or the index of the entry as argument. :param entry: Entry or index of entry to remove. ''' - if isinstance(entry, FeedEntry): + if isinstance(entry, Episode): self.__feed_entries.remove(entry) else: self.__feed_entries.pop(entry) diff --git a/feedgen/entry.py b/feedgen/item.py similarity index 99% rename from feedgen/entry.py rename to feedgen/item.py index 3dc7524..c28d807 100644 --- a/feedgen/entry.py +++ b/feedgen/item.py @@ -17,9 +17,8 @@ from feedgen.compat import string_types -class FeedEntry(object): - '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item - node. +class Episode(object): + '''Class representing an episode in a podcast. Corresponds to an RSS Item element. ''' def __init__(self): diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 3d555b5..38c4eee 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -8,13 +8,13 @@ import unittest from lxml import etree -from ..feed import FeedGenerator +from ..feed import Podcast class TestSequenceFunctions(unittest.TestCase): def setUp(self): - fg = FeedGenerator() + fg = Podcast() self.title = 'Some Testfeed' fg.title(self.title) @@ -50,7 +50,7 @@ def test_checkEntryContent(self): assert len(fg.entry()) != None def test_removeEntryByIndex(self): - fg = FeedGenerator() + fg = Podcast() self.feedId = 'http://example.com' self.title = 'Some Testfeed' @@ -62,7 +62,7 @@ def test_removeEntryByIndex(self): assert len(fg.entry()) == 0 def test_removeEntryByEntry(self): - fg = FeedGenerator() + fg = Podcast() self.feedId = 'http://example.com' self.title = 'Some Testfeed' @@ -75,7 +75,7 @@ def test_removeEntryByEntry(self): assert len(fg.entry()) == 0 def test_categoryHasDomain(self): - fg = FeedGenerator() + fg = Podcast() fg.title('some title') fg.link( href='http://www.dontcare.com') fg.description('description') diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index a396b77..0cf30d7 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -9,13 +9,13 @@ import unittest from lxml import etree -from ..feed import FeedGenerator +from ..feed import Podcast class TestSequenceFunctions(unittest.TestCase): def setUp(self): - fg = FeedGenerator() + fg = Podcast() self.nsRss = "http://purl.org/rss/1.0/modules/content/" diff --git a/readme.md b/readme.md index 89746ef..5fdb5c3 100644 --- a/readme.md +++ b/readme.md @@ -33,11 +33,11 @@ install lxml and dateutils. Create a Feed ------------- -To create a feed simply instantiate the FeedGenerator class and insert some +To create a feed simply instantiate the Podcast class and insert some data:: - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() + >>> from feedgen.feed import Podcast + >>> fg = Podcast() >>> fg.title('Some Testfeed') >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) >>> fg.link( href='http://example.com', rel='alternate' ) @@ -73,17 +73,17 @@ After that you can generate RSS by calling: 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 Episode objects and +append them to the list of entries in the Podcast. The most convenient +way to go is to use the Podcast itself for the instantiation of the +Episode object: >>> fe = fg.add_entry() >>> fe.guid('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 +automatically generate a new Episode object, append it to the feeds internal list of entries and return it, so that additional data can be added. -------------------------- @@ -93,8 +93,8 @@ Using the podcast features Most iTunes-specific features are available as methods that start with `itunes_`, although most features are platform agnostic. - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() + >>> from feedgen.feed import Podcast + >>> fg = Podcast() ... >>> fg.itunes_category('Technology', 'Podcasting') ... From f730d9e8d62e93403aaead4e54724cd6876bf757 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 00:00:49 +0200 Subject: [PATCH 017/200] Clean up on channels's pubDate vs LastBuildDate requirements.txt is added with the pip packages you need, to ease setting up a development environment. I have added the pytz package at the same time, since you cannot possibly deal with timezones without. pubDate has a new default value, which is calculated from the pubDate of its items. This is put to use in __main__.py. pubDate was renamed to published, to communicate its purpose as the date of the last time the feed was published (ie., item added). The lastBuildDate alias for updated was removed, to better communicate updated's purpose as the last time the content of the feed was updated. It still has the default value of now(), which means it can be used by clients to ensure that no new items have been published between pubDate and now(). It is also consistent with the tag name, although the spec says it should be "the last time the content of the channel *changed*". Funny enough, by setting lastBuildDate to the current time, we are making it so that the content of the channel (namely lastBuildDate) actually was last changed at the time indicated by lastBuildDate, if that makes any sense. A self-fulfilling prophecy! --- feedgen/__main__.py | 3 +++ feedgen/feed.py | 43 ++++++++++++++++++------------------------- requirements.txt | 3 +++ 3 files changed, 24 insertions(+), 25 deletions(-) create mode 100644 requirements.txt diff --git a/feedgen/__main__.py b/feedgen/__main__.py index cedb30c..1773e7e 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -10,6 +10,8 @@ from feedgen.feed import Podcast import sys +import datetime +import pytz def print_enc(s): '''Print function compatible with both python2 and python3 accepting strings @@ -67,6 +69,7 @@ def print_enc(s): fg.itunes_new_feed_url('http://example.com/new-feed.rss') fg.itunes_owner('John Doe', 'john@example.com') fe.itunes_author('Lars Kiesow') + fe.pubdate(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) print_enc(fg.rss_str(pretty=True)) elif arg.endswith('rss'): fg.rss_file(arg) diff --git a/feedgen/feed.py b/feedgen/feed.py index 189a58e..046320f 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -122,9 +122,19 @@ def _create_rss(self): if self.__rss_managingEditor: managingEditor = etree.SubElement(channel, 'managingEditor') managingEditor.text = self.__rss_managingEditor - if self.__rss_pubDate: + + if not self.__rss_pubDate: + episode_dates = [e.pubdate() for e in self.__feed_entries if e.pubdate() is not None] + if episode_dates: + actual_pubDate = max(episode_dates) + else: + actual_pubDate = None + else: + actual_pubDate = self.__rss_pubDate + if actual_pubDate: pubDate = etree.SubElement(channel, 'pubDate') - pubDate.text = formatRFC2822(self.__rss_pubDate) + pubDate.text = formatRFC2822(actual_pubDate) + if self.__rss_skipHours: skipHours = etree.SubElement(channel, 'skipHours') for h in self.__rss_skipHours: @@ -260,7 +270,6 @@ def updated(self, updated=None): :type updated: str or datetime.datetime :returns: Modification date as datetime.datetime ''' - # TODO: Standardize on one way to set publication date if not updated is None: if isinstance(updated, string_types): updated = dateutil.parser.parse(updated) @@ -273,26 +282,6 @@ def updated(self, updated=None): return self.__rss_lastBuildDate - 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. - :type updated: str or datetime.datetime - :returns: Modification date as datetime.datetime - ''' - return self.updated( lastBuildDate ) - - def link(self, href=None): '''Get or set the feed's link (website). @@ -412,7 +401,7 @@ def managingEditor(self, managingEditor=None): return self.__rss_managingEditor - def pubDate(self, pubDate=None): + def published(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 @@ -422,10 +411,14 @@ def pubDate(self, pubDate=None): datetime.datetime object. In any case it is necessary that the value include timezone information. + Default value + If not set, published will use the value of the episode with the + latest publication date (which may be in the future). If there + are no episodes, the publication date is omitted from the feed. + :param pubDate: The publication date. :returns: Publication date as datetime.datetime ''' - # TODO Add/rename to lastBuildDate if not pubDate is None: if isinstance(pubDate, string_types): pubDate = dateutil.parser.parse(pubDate) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..31df655 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +dateutils +lxml +pytz From fa492f3f749c4f4469eacce0e2411d00ff870ecb Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 16:56:38 +0200 Subject: [PATCH 018/200] Reimagine API related to RSS items Instead of using the RSS term "item", the API is changed to using the podcast term "episode". Additionally, the list of episodes is no longer encapsulated and is public to users, removing the need for a few methods. This means users can add whatever they want to the episode list, but I trust their judgement. I'm a bit unsure if it is okay to have two ways to add new episodes, though.. What class is used by add_episode (earlier add_entry) is now determined by the property episode_class. This means that subclasses of Podcast easily can change what Episode (sub)class is used. I need to think more about how to avoid confusion around feedgen.item.Episode and feedgen.feed.Podcast.episode_class. --- feedgen/__init__.py | 6 +- feedgen/__main__.py | 2 +- feedgen/feed.py | 150 ++++++++++++++++++------------------ feedgen/tests/test_entry.py | 33 ++++---- readme.md | 6 +- 5 files changed, 100 insertions(+), 97 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 843fca7..8bb6848 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -78,11 +78,11 @@ way to go is to use the Podcast itself for the instantiation of the Episode object:: - >>> fe = fg.add_entry() + >>> fe = fg.add_episode() >>> fe.guid('http://lernfunk.de/media/654321/1') >>> fe.title('The First Episode') - The FeedGenerators method `add_entry(...)` without argument provides will + The FeedGenerators method `add_episode(...)` without argument provides will automatically generate a new Episode object, append it to the feeds internal list of entries and return it, so that additional data can be added. @@ -98,7 +98,7 @@ ... >>> fg.itunes_category('Technology', 'Podcasting') ... - >>> fe = fg.add_entry() + >>> fe = fg.add_episode() >>> fe.guid('http://lernfunk.de/media/654321/1/file.mp3') >>> fe.title('The First Episode') >>> fe.description('Enjoy our first episode.') diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 1773e7e..cb115b3 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -46,7 +46,7 @@ def print_enc(s): fg.copyright('cc-by') fg.description('This is a cool feed!') fg.language('de') - fe = fg.add_entry() + fe = fg.add_episode() fe.guid('http://lernfunk.de/_MEDIAID_123#1') fe.title('First Element') fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen diff --git a/feedgen/feed.py b/feedgen/feed.py index 046320f..12e256e 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -19,6 +19,7 @@ import sys from feedgen.compat import string_types import collections +import inspect _feedgen_version = feedgen.version.version_str @@ -30,7 +31,10 @@ class Podcast(object): def __init__(self): - self.__feed_entries = [] + self.__episodes = [] + """The list used by self.episodes.""" + self.__episode_class = Episode + """The internal value used by self.episode_class.""" ## RSS # http://www.rssboard.org/rss-specification @@ -64,6 +68,71 @@ def __init__(self): self.__itunes_owner = None self.__itunes_subtitle = None + @property + def episodes(self): + """List of episodes that are part of this podcast. + + This property is read-only, in the sense that you cannot assign a new list to it. + You are, however, able to add, get and remove individual episodes from the (existing) list. + + See :py:meth:`.add_episode` for an easy way to create new episodes and assign them to this podcast + in one call. + """ + return self.__episodes + + @property + def episode_class(self): + """The class that is used by self.add_episode() when creating new episode objects. + + Defaults to Episode, at least in Podcast. + + When assigning a new value to episode_class, you must make sure the new value (1) is a class and not an + instance, and (2) is a subclass of Episode (or is Episode itself). + + This property exists so you can change which class episodes should have, without needing to change the code + that creates those episodes. Thus, changing this property changes what class is used by self.add_episode(). + An example would be if you created a subclass of Podcast together with a + subclass of Episode, and wanted users of your new Podcast subclass to be using your new Episode subclass + automatically. All you need to do, is to change the initial value of episode_class in your Podcast subclass. + Users, on the other hand, won't have to change their code when changing between different + subclasses of Podcast that expect different subclasses of Episode. + + It is still possible for users to hardcode what Episode subclass they want to use, either by calling its + constructor without using episode_class, or by overriding the initial value of episode_class. + + Example of use:: + + >>> # Create new podcast + >>> from feedgen.feed import Podcast + >>> p = Podcast() + >>> + >>> # Here's how you would create a new episode object, the OK way + >>> episode1 = p.episode_class() + >>> p.episodes.append(episode1) + >>> episode1.title("My awesome episode") + >>> + >>> # Best way to create new episode object (it is added to the podcast automatically) + >>> episode2 = p.add_episode() + >>> episode2.title("My even more awesome episode") + >>> + >>> # !!! DON'T DO THE FOLLOWING, unless you want to hard code what class is used !!! + >>> from feedgen.item import Episode + >>> episode3 = Episode() + >>> p.episodes.append(episode3) + >>> episode3.title("My awful episode :(") + """ + return self.__episode_class + + @episode_class.setter + def episode_class(self, value): + if not inspect.isclass(value): + raise ValueError("New episode_class must NOT be an _instance_ of the desired class, but rather the class " + "itself. You can generally achieve this by removing the parenthesis from the " + "constructor call. For example, use Episode, not Episode().") + elif issubclass(value, Episode): + self.__episode_class = value + else: + raise ValueError("New episode_class must be Episode or a descendant of it (so the API still works).") def _create_rss(self): '''Create an RSS feed xml structure containing all previously set fields. @@ -124,7 +193,7 @@ def _create_rss(self): managingEditor.text = self.__rss_managingEditor if not self.__rss_pubDate: - episode_dates = [e.pubdate() for e in self.__feed_entries if e.pubdate() is not None] + episode_dates = [e.pubdate() for e in self.episodes if e.pubdate() is not None] if episode_dates: actual_pubDate = max(episode_dates) else: @@ -191,7 +260,7 @@ def _create_rss(self): subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) subtitle.text = self.__itunes_subtitle - for entry in self.__feed_entries: + for entry in self.episodes: item = entry.rss_entry() channel.append(item) @@ -698,7 +767,7 @@ def itunes_subtitle(self, itunes_subtitle=None): return self.__itunes_subtitle - def add_entry(self, feedEntry=None): + def add_episode(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. @@ -709,81 +778,14 @@ def add_entry(self, feedEntry=None): Example:: ... - >>> entry = feedgen.add_entry() + >>> entry = feedgen.add_episode() >>> entry.title('First feed entry') ''' if feedEntry is None: - feedEntry = Episode() + feedEntry = self.episode_class() version = sys.version_info[0] - self.__feed_entries.append( feedEntry ) + self.episodes.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 Episode 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 Episode objects. - - This method takes both a single Episode object or a list of objects. - - :param entry: Episode object or list of Episode 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.load_extension( extname, 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 - Episode object to remove or the index of the entry as argument. - - :param entry: Entry or index of entry to remove. - ''' - if isinstance(entry, Episode): - 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) diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 38c4eee..66b7cc8 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -19,16 +19,17 @@ def setUp(self): fg.title(self.title) - fe = fg.add_entry() + fe = fg.add_episode() fe.guid('http://lernfunk.de/media/654321/1') fe.title('The First Episode') - #Use also the different name add_item - fe = fg.add_item() + #Use also the list directly + fe = fg.episode_class() + fg.episodes.append(fe) fe.guid('http://lernfunk.de/media/654321/1') fe.title('The Second Episode') - fe = fg.add_entry() + fe = fg.add_episode() fe.guid('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') @@ -37,49 +38,49 @@ def setUp(self): def test_checkEntryNumbers(self): fg = self.fg - assert len(fg.entry()) == 3 + assert len(fg.episodes) == 3 def test_checkItemNumbers(self): fg = self.fg - assert len(fg.item()) == 3 + assert len(fg.episodes) == 3 def test_checkEntryContent(self): fg = self.fg - assert len(fg.entry()) != None + assert len(fg.episodes) is not None def test_removeEntryByIndex(self): fg = Podcast() self.feedId = 'http://example.com' self.title = 'Some Testfeed' - fe = fg.add_entry() + fe = fg.add_episode() fe.guid('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 + assert len(fg.episodes) == 1 + fg.episodes.pop(0) + assert len(fg.episodes) == 0 def test_removeEntryByEntry(self): fg = Podcast() self.feedId = 'http://example.com' self.title = 'Some Testfeed' - fe = fg.add_entry() + fe = fg.add_episode() fe.guid('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') - assert len(fg.entry()) == 1 - fg.remove_entry(fe) - assert len(fg.entry()) == 0 + assert len(fg.episodes) == 1 + fg.episodes.remove(fe) + assert len(fg.episodes) == 0 def test_categoryHasDomain(self): fg = Podcast() fg.title('some title') fg.link( href='http://www.dontcare.com') fg.description('description') - fe = fg.add_entry() + fe = fg.add_episode() fe.guid('http://lernfunk.de/media/654321/1') fe.title('some title') fe.category([ diff --git a/readme.md b/readme.md index 5fdb5c3..ccde58f 100644 --- a/readme.md +++ b/readme.md @@ -78,11 +78,11 @@ append them to the list of entries in the Podcast. The most convenient way to go is to use the Podcast itself for the instantiation of the Episode object: - >>> fe = fg.add_entry() + >>> fe = fg.add_episode() >>> fe.guid('http://lernfunk.de/media/654321/1') >>> fe.title('The First Episode') -The FeedGenerators method `add_entry(...)` without argument provides will +The FeedGenerators method `add_episode(...)` without argument provides will automatically generate a new Episode object, append it to the feeds internal list of entries and return it, so that additional data can be added. @@ -98,7 +98,7 @@ although most features are platform agnostic. ... >>> fg.itunes_category('Technology', 'Podcasting') ... - >>> fe = fg.add_entry() + >>> fe = fg.add_episode() >>> fe.guid('http://lernfunk.de/media/654321/1/file.mp3') >>> fe.title('The First Episode') >>> fe.description('Enjoy our first episode.') From eb80d9388fbf4a7faf15a31fcdb3cb68baf39b30 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 17:05:45 +0200 Subject: [PATCH 019/200] Fix docs pointing to item.py's old name --- doc/{api.entry.rst => api.item.rst} | 2 +- doc/api.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename doc/{api.entry.rst => api.item.rst} (77%) diff --git a/doc/api.entry.rst b/doc/api.item.rst similarity index 77% rename from doc/api.entry.rst rename to doc/api.item.rst index eab7d2f..2903d12 100644 --- a/doc/api.entry.rst +++ b/doc/api.item.rst @@ -3,5 +3,5 @@
Contents
-.. automodule:: feedgen.entry +.. automodule:: feedgen.item :members: diff --git a/doc/api.rst b/doc/api.rst index 51ade62..90610c5 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -11,5 +11,5 @@ Contents: :maxdepth: 2 api.feed - api.entry + api.item api.util From 80bd2828fb913b03dfe48abd23935012254e9c3a Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 19:40:01 +0200 Subject: [PATCH 020/200] Rename Episode to BaseEpisode, and episode_class to Episode The idea is to make it obvious for everyone that they should use the episode_class property. To achieve that, I'm renaming it so it looks and behaves like a class (except it is bound to an instance of Podcast). But everyone would confuse Episode with Episode (rightfully so), which is why the class called Episode is renamed to BaseEpisode, to show its purpose as the episode class which everyone else should extend when needed. --- feedgen/__init__.py | 8 ++++---- feedgen/feed.py | 37 +++++++++++++++++++------------------ feedgen/item.py | 8 ++++++-- feedgen/tests/test_entry.py | 6 +++--- readme.md | 12 ++++++------ 5 files changed, 38 insertions(+), 33 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 8bb6848..bfd47f2 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -73,17 +73,17 @@ Add Feed Entries ---------------- - To add entries (items) to a feed you need to create new Episode objects and + To add entries (items) to a feed you need to create new BaseEpisode objects and append them to the list of entries in the Podcast. The most convenient way to go is to use the Podcast itself for the instantiation of the - Episode object:: + BaseEpisode object:: >>> fe = fg.add_episode() >>> fe.guid('http://lernfunk.de/media/654321/1') >>> fe.title('The First Episode') - The FeedGenerators method `add_episode(...)` without argument provides will - automatically generate a new Episode object, append it to the feeds internal + The Podcast method `add_episode(...)` without argument provides will + automatically generate a new BaseEpisode object, append it to the feeds internal list of entries and return it, so that additional data can be added. -------------------------- diff --git a/feedgen/feed.py b/feedgen/feed.py index 12e256e..598e8dd 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -13,7 +13,7 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.item import Episode +from feedgen.item import BaseEpisode from feedgen.util import ensure_format, formatRFC2822 import feedgen.version import sys @@ -33,8 +33,8 @@ class Podcast(object): def __init__(self): self.__episodes = [] """The list used by self.episodes.""" - self.__episode_class = Episode - """The internal value used by self.episode_class.""" + self.__episode_class = BaseEpisode + """The internal value used by self.Episode.""" ## RSS # http://www.rssboard.org/rss-specification @@ -81,24 +81,25 @@ def episodes(self): return self.__episodes @property - def episode_class(self): + def Episode(self): """The class that is used by self.add_episode() when creating new episode objects. - Defaults to Episode, at least in Podcast. + Defaults to BaseEpisode, at least in Podcast. - When assigning a new value to episode_class, you must make sure the new value (1) is a class and not an - instance, and (2) is a subclass of Episode (or is Episode itself). + When assigning a new value to Episode, you must make sure the new value + (1) is a class and not an instance, and (2) is a subclass of BaseEpisode + (or is Episode itself). This property exists so you can change which class episodes should have, without needing to change the code that creates those episodes. Thus, changing this property changes what class is used by self.add_episode(). An example would be if you created a subclass of Podcast together with a subclass of Episode, and wanted users of your new Podcast subclass to be using your new Episode subclass - automatically. All you need to do, is to change the initial value of episode_class in your Podcast subclass. + automatically. All you need to do, is to change the initial value of Episode in your Podcast subclass. Users, on the other hand, won't have to change their code when changing between different subclasses of Podcast that expect different subclasses of Episode. It is still possible for users to hardcode what Episode subclass they want to use, either by calling its - constructor without using episode_class, or by overriding the initial value of episode_class. + constructor without using Episode, or by overriding the initial value of Episode. Example of use:: @@ -107,7 +108,7 @@ def episode_class(self): >>> p = Podcast() >>> >>> # Here's how you would create a new episode object, the OK way - >>> episode1 = p.episode_class() + >>> episode1 = p.Episode() >>> p.episodes.append(episode1) >>> episode1.title("My awesome episode") >>> @@ -116,23 +117,23 @@ def episode_class(self): >>> episode2.title("My even more awesome episode") >>> >>> # !!! DON'T DO THE FOLLOWING, unless you want to hard code what class is used !!! - >>> from feedgen.item import Episode - >>> episode3 = Episode() + >>> from feedgen.item import BaseEpisode + >>> episode3 = BaseEpisode() >>> p.episodes.append(episode3) >>> episode3.title("My awful episode :(") """ return self.__episode_class - @episode_class.setter - def episode_class(self, value): + @Episode.setter + def Episode(self, value): if not inspect.isclass(value): - raise ValueError("New episode_class must NOT be an _instance_ of the desired class, but rather the class " + raise ValueError("New Episode must NOT be an _instance_ of the desired class, but rather the class " "itself. You can generally achieve this by removing the parenthesis from the " "constructor call. For example, use Episode, not Episode().") - elif issubclass(value, Episode): + elif issubclass(value, BaseEpisode): self.__episode_class = value else: - raise ValueError("New episode_class must be Episode or a descendant of it (so the API still works).") + raise ValueError("New Episode must be Episode or a descendant of it (so the API still works).") def _create_rss(self): '''Create an RSS feed xml structure containing all previously set fields. @@ -783,7 +784,7 @@ def add_episode(self, feedEntry=None): ''' if feedEntry is None: - feedEntry = self.episode_class() + feedEntry = self.Episode() version = sys.version_info[0] diff --git a/feedgen/item.py b/feedgen/item.py index c28d807..d819ab2 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -17,8 +17,12 @@ from feedgen.compat import string_types -class Episode(object): - '''Class representing an episode in a podcast. Corresponds to an RSS Item element. +class BaseEpisode(object): + '''Class representing an episode in a podcast. Corresponds to an RSS Item. + + Its name indicates that this is the superclass for all episode classes. + It is not meant to indicate that this class misses functionality; in 99% + of all cases, this class is the right one to use for episodes. ''' def __init__(self): diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 66b7cc8..746a2ee 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -24,7 +24,7 @@ def setUp(self): fe.title('The First Episode') #Use also the list directly - fe = fg.episode_class() + fe = fg.Episode() fg.episodes.append(fe) fe.guid('http://lernfunk.de/media/654321/1') fe.title('The Second Episode') @@ -57,7 +57,7 @@ def test_removeEntryByIndex(self): fe = fg.add_episode() fe.guid('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') + fe.title('The Third BaseEpisode') assert len(fg.episodes) == 1 fg.episodes.pop(0) assert len(fg.episodes) == 0 @@ -69,7 +69,7 @@ def test_removeEntryByEntry(self): fe = fg.add_episode() fe.guid('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') + fe.title('The Third BaseEpisode') assert len(fg.episodes) == 1 fg.episodes.remove(fe) diff --git a/readme.md b/readme.md index ccde58f..fa16a87 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ -============= +============================================== Feedgenerator (forked) - Podcasting for humans -============= +============================================== Ignore: [![Build Status](https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master) @@ -73,17 +73,17 @@ After that you can generate RSS by calling: Add Feed Entries ---------------- -To add entries (items) to a feed you need to create new Episode objects and +To add entries (items) to a feed you need to create new BaseEpisode objects and append them to the list of entries in the Podcast. The most convenient way to go is to use the Podcast itself for the instantiation of the -Episode object: +BaseEpisode object: >>> fe = fg.add_episode() >>> fe.guid('http://lernfunk.de/media/654321/1') - >>> fe.title('The First Episode') + >>> fe.title('The First BaseEpisode') The FeedGenerators method `add_episode(...)` without argument provides will -automatically generate a new Episode object, append it to the feeds internal +automatically generate a new BaseEpisode object, append it to the feeds internal list of entries and return it, so that additional data can be added. -------------------------- From 43530e59889f268c4b61ff8069547fc48f3fb03f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 19:55:41 +0200 Subject: [PATCH 021/200] Convert single-quoted docstrings to double-quoted In other words, from ''' to """ --- feedgen/feed.py | 116 ++++++++++++++++++++++----------------------- feedgen/item.py | 90 +++++++++++++++++------------------ feedgen/util.py | 12 ++--- feedgen/version.py | 4 +- 4 files changed, 111 insertions(+), 111 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 598e8dd..ad7754a 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -''' +""" feedgen.feed ~~~~~~~~~~~~ @@ -7,7 +7,7 @@ :license: FreeBSD and LGPL, see license.* for more details. -''' +""" from lxml import etree from datetime import datetime @@ -26,8 +26,8 @@ class Podcast(object): - '''Class representing one podcast feed. - ''' + """Class representing one podcast feed. + """ def __init__(self): @@ -136,10 +136,10 @@ def Episode(self, value): raise ValueError("New Episode must be Episode or a descendant of it (so the API still works).") def _create_rss(self): - '''Create an RSS feed xml structure containing all previously set fields. + """Create an RSS feed xml structure containing all previously set fields. :returns: Tuple containing the feed root element and the element tree. - ''' + """ nsmap = { 'atom': 'http://www.w3.org/2005/Atom', @@ -271,7 +271,7 @@ def _create_rss(self): def rss_str(self, pretty=False, encoding='UTF-8', xml_declaration=True): - '''Generates an RSS feed and returns the feed XML as string. + """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. @@ -282,7 +282,7 @@ def rss_str(self, pretty=False, encoding='UTF-8', output (Default: enabled). :type xml_declaration: bool :returns: String representation of the RSS feed. - ''' + """ feed, doc = self._create_rss() return etree.tostring(feed, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) @@ -290,7 +290,7 @@ def rss_str(self, pretty=False, encoding='UTF-8', def rss_file(self, filename, pretty=False, encoding='UTF-8', xml_declaration=True): - '''Generates an RSS feed and write the resulting XML to a file. + """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. :type filename: str or fd @@ -302,14 +302,14 @@ def rss_file(self, filename, pretty=False, :param xml_declaration: If an XML declaration should be added to the output (Default: enabled). :type xml_declaration: bool - ''' + """ feed, doc = self._create_rss() 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 + """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. @@ -317,14 +317,14 @@ def title(self, title=None): :param title: The new title of the feed. :type title: str :returns: The feeds title. - ''' + """ if not title is None: self.__rss_title = title return self.__rss_title def updated(self, updated=None): - '''Set or get the updated value which indicates the last time the feed + """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 @@ -339,7 +339,7 @@ def updated(self, updated=None): :param updated: The modification date. :type updated: str or datetime.datetime :returns: Modification date as datetime.datetime - ''' + """ if not updated is None: if isinstance(updated, string_types): updated = dateutil.parser.parse(updated) @@ -353,7 +353,7 @@ def updated(self, updated=None): def link(self, href=None): - '''Get or set the feed's link (website). + """Get or set the feed's link (website). :param href: URI of this feed's website. @@ -362,7 +362,7 @@ def link(self, href=None): >>> feedgen.link( href='http://example.com/') [{'href':'http://example.com/', 'rel':'self'}] - ''' + """ if not href is None: self.__rss_link = href return self.__rss_link @@ -370,7 +370,7 @@ def link(self, href=None): 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 + """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. @@ -380,7 +380,7 @@ def cloud(self, domain=None, port=None, path=None, registerProcedure=None, :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} @@ -388,13 +388,13 @@ def cloud(self, domain=None, port=None, path=None, registerProcedure=None, def generator(self, generator=None, version=None, uri=None): - '''Get or the generator of the feed which identifies the software used to + """Get or the generator of the feed which identifies the software used to generate the feed, for debugging and other purposes. :param generator: Software used to create the feed. :param version: (Optional) Version of the software. :param uri: (Optional) URI the software can be found. - ''' + """ if not generator is None: self.__rss_generator = generator + \ (("/" + str(version)) if version is not None else "") + \ @@ -403,11 +403,11 @@ def generator(self, generator=None, version=None, uri=None): def copyright(self, copyright=None): - '''Get or set the copyright notice for content in the channel. + """Get or set the copyright notice for content in the channel. :param copyright: The copyright notice. :returns: The copyright notice. - ''' + """ if not copyright is None: self.__rss_copyright = copyright @@ -415,21 +415,21 @@ def copyright(self, copyright=None): def description(self, description=None): - '''Set and get the description of the feed, + """Set and get the description of the feed, which is a phrase or sentence describing the channel. It is mandatory for RSS feeds. :param description: Description of the channel. :returns: Description of the channel. - ''' + """ if not description is None: self.__rss_description = description return self.__rss_description def docs(self, docs=None): - '''Get or set the docs value of the feed. It + """Get or set the docs value of the feed. 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 @@ -439,40 +439,40 @@ def docs(self, docs=None): :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 + """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. 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 return self.__rss_language def managingEditor(self, managingEditor=None): - '''Set or get the value for managingEditor which is the email address for + """Set or get the value for managingEditor which is the email address for person responsible for editorial content. :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 published(self, pubDate=None): - '''Set or get the publication date for the content in the channel. For + """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. @@ -488,7 +488,7 @@ def published(self, pubDate=None): :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) @@ -502,7 +502,7 @@ def published(self, pubDate=None): def skipHours(self, hours=None, replace=False): - '''Set or get the value of skipHours, a hint for aggregators telling them + """Set or get the value of skipHours, a hint for aggregators telling them which hours they can skip. This method can be called with an hour or a list of hours. The hours are @@ -511,7 +511,7 @@ def skipHours(self, hours=None, replace=False): :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] @@ -525,7 +525,7 @@ def skipHours(self, hours=None, replace=False): def skipDays(self, days=None, replace=False): - '''Set or get the value of skipDays, a hint for aggregators telling them + """Set or get the value of skipDays, a hint for aggregators telling them which days they can skip. This method can be called with a day name or a list of day names. The days are @@ -534,7 +534,7 @@ def skipDays(self, days=None, replace=False): :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] @@ -549,18 +549,18 @@ def skipDays(self, days=None, replace=False): def webMaster(self, webMaster=None): - '''Get and set the value of webMaster, which represents the email address + """Get and set the value of webMaster, which represents the email address for the person responsible for technical issues relating to the feed. :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 itunes_author(self, itunes_author=None): - '''Get or set the itunes:author. The content of this tag is shown in the + """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 . @@ -568,24 +568,24 @@ def itunes_author(self, itunes_author=None): :param itunes_author: The author of the podcast. :type itunes_author: str :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 + """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, itunes_subcategory=None): - '''Get or set the ITunes category which appears in the category column + """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 @@ -596,7 +596,7 @@ def itunes_category(self, itunes_category=None, itunes_subcategory=None): :param itunes_subcategory: Subcategory of the podcast, unescaped. The subcategory need not be set. :type itunes_subcategory: str :returns: Dictionary which has category with key 'cat', and optionally subcategory with key 'sub'. - ''' + """ if not itunes_category is None: if not itunes_category in self._itunes_categories.keys(): raise ValueError('Invalid category %s' % itunes_category) @@ -640,7 +640,7 @@ def itunes_category(self, itunes_category=None, itunes_subcategory=None): } def itunes_image(self, itunes_image=None): - '''Get or set the image for the podcast. This tag specifies the artwork + """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 @@ -660,7 +660,7 @@ def itunes_image(self, itunes_image=None): :param itunes_image: Image of the podcast. :type itunes_image: str :returns: Image of the podcast. - ''' + """ if not itunes_image is None: lowercase_itunes_image = itunes_image.lower() if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): @@ -669,7 +669,7 @@ def itunes_image(self, itunes_image=None): return self.__itunes_image def itunes_explicit(self, itunes_explicit=None): - '''Get or the the itunes:explicit value of the podcast. This tag should + """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". @@ -685,7 +685,7 @@ def itunes_explicit(self, itunes_explicit=None): as blank. :type itunes_explicit: str :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 "%s" for explicit tag' % itunes_explicit) @@ -693,7 +693,7 @@ def itunes_explicit(self, itunes_explicit=None): 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 + """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 @@ -704,7 +704,7 @@ def itunes_complete(self, itunes_complete=None): :param itunes_complete: If the podcast is complete. :type itunes_complete: bool or str :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 "%s" for complete tag' % itunes_complete) @@ -716,7 +716,7 @@ def itunes_complete(self, itunes_complete=None): 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 + """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 @@ -726,13 +726,13 @@ def itunes_new_feed_url(self, itunes_new_feed_url=None): :param itunes_new_feed_url: New feed URL. :type itunes_new_feed_url: str :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 + """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. @@ -744,7 +744,7 @@ def itunes_owner(self, name=None, email=None): :param email: The feed owner's email. :type email: str :returns: Data of the owner of the feed. - ''' + """ if not name is None: if name and email: self.__itunes_owner = {'name': name, 'email': email} @@ -755,21 +755,21 @@ 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 + """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. :type itunes_subtitle: str :returns: Subtitle of the podcast. - ''' + """ if not itunes_subtitle is None: self.__itunes_subtitle = itunes_subtitle return self.__itunes_subtitle def add_episode(self, feedEntry=None): - '''This method will add a new entry to the feed. If the feedEntry + """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. @@ -782,7 +782,7 @@ def add_episode(self, feedEntry=None): >>> entry = feedgen.add_episode() >>> entry.title('First feed entry') - ''' + """ if feedEntry is None: feedEntry = self.Episode() diff --git a/feedgen/item.py b/feedgen/item.py index d819ab2..4fa7b35 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -''' +""" feedgen.entry ~~~~~~~~~~~~~ :copyright: 2013, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. -''' +""" import collections from lxml import etree @@ -18,12 +18,12 @@ class BaseEpisode(object): - '''Class representing an episode in a podcast. Corresponds to an RSS Item. + """Class representing an episode in a podcast. Corresponds to an RSS Item. Its name indicates that this is the superclass for all episode classes. It is not meant to indicate that this class misses functionality; in 99% of all cases, this class is the right one to use for episodes. - ''' + """ def __init__(self): # RSS @@ -53,7 +53,7 @@ def __init__(self): def rss_entry(self, extensions=True): - '''Create a RSS item and return it.''' + """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') @@ -144,31 +144,31 @@ def rss_entry(self, extensions=True): def title(self, title=None): - '''Get or set the title value of the entry. It should contain a human + """Get or set the title value of the entry. It should contain a human readable title for the entry. Title is mandatory and should not be blank. :param title: The new title of the entry. :returns: The entriess title. - ''' + """ if not title is None: self.__rss_title = title return self.__rss_title def guid(self, guid=None): - '''Get or set the entries guid which is a string that uniquely identifies + """Get or set the entries guid which is a string that uniquely identifies the item. :param guid: Id of the entry. :returns: Id of the entry. - ''' + """ if not guid is None: self.__rss_guid = guid return self.__rss_guid def author(self, author=None, replace=False, **kwargs): - '''Get or set autor data. An author element is a dict containing a name and + """Get or set autor data. An author element is a dict containing a name and an email adress. Email is mandatory. This method can be called with: @@ -195,7 +195,7 @@ def author(self, author=None, replace=False, **kwargs): >>> 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: @@ -208,7 +208,7 @@ def author(self, author=None, replace=False, **kwargs): def content(self, content=None, type=None): - '''Get or set the content 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. If the content is set (not linked) it will also set rss:description. @@ -216,7 +216,7 @@ def content(self, content=None, type=None): :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 content is None: self.__rss_content = {'content':content} if not type is None: @@ -225,28 +225,28 @@ def content(self, content=None, type=None): def link(self, href=None): - '''Get or set the link to the full version of this episode description. + """Get or set the link to the full version of this episode description. :param href: the URI of the referenced resource (typically a Web page) :returns: The current link URI. - ''' + """ if not href is None: self.__rss_link = href return self.__rss_link def description(self, description=None): - '''Get or set the description value which is the item synopsis. + """Get or set the description value which is the item synopsis. :param description: Description of the entry. :returns: The entries description. - ''' + """ if not description is None: self.__rss_description = description return self.__rss_description def category(self, category=None, replace=False, **kwargs): - '''Get or set categories that the feed belongs to. + """Get or set categories that the feed belongs to. This method can be called with: @@ -265,7 +265,7 @@ def category(self, category=None, replace=False, **kwargs): :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: @@ -283,7 +283,7 @@ def category(self, category=None, replace=False, **kwargs): def published(self, published=None): - '''Set or get the published value which contains the time of the initial + """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 @@ -292,7 +292,7 @@ def published(self, published=None): :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) @@ -306,40 +306,40 @@ def published(self, published=None): def pubdate(self, pubDate=None): - '''Get or set the pubDate of the entry which indicates when the entry was + """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 comments(self, comments=None): - '''Get or set the the value of comments which is the url of the comments + """Get or set the the value of comments which is the url of the comments page for the item. :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 + """Get or set the value of enclosure which describes a media object that is attached to this item. :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.__rss_enclosure = {'url': url, 'length': length, 'type': type} return self.__rss_enclosure def itunes_author(self, itunes_author=None): - '''Get or set the itunes:author of the podcast episode. The content of + """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 @@ -348,26 +348,26 @@ def itunes_author(self, itunes_author=None): :param itunes_author: The author of the podcast. :type itunes_author: str :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 + """Get or set the ITunes block attribute. Use this to prevent episodes from appearing in the iTunes podcast directory. Note that the episode can still be found by inspecting the XML, thus it is public. :param itunes_block: Block podcast episodes. :type itunes_block: bool :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 + """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. @@ -387,7 +387,7 @@ def itunes_image(self, itunes_image=None): :param itunes_image: Image of the podcast. :type itunes_image: str :returns: Image of the podcast. - ''' + """ if not itunes_image is None: lowercase_itunes_image = itunes_image.lower() if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): @@ -396,7 +396,7 @@ def itunes_image(self, itunes_image=None): return self.__itunes_image def itunes_duration(self, itunes_duration=None): - '''Get or set the duration of the podcast episode. The content of this + """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, @@ -409,7 +409,7 @@ def itunes_duration(self, itunes_duration=None): :param itunes_duration: Duration of the podcast episode. :type itunes_duration: str or int :returns: Duration of the podcast episode. - ''' + """ if not itunes_duration is None: itunes_duration = str(itunes_duration) if len(itunes_duration.split(':')) > 3 or \ @@ -419,7 +419,7 @@ def itunes_duration(self, itunes_duration=None): return self.itunes_duration def itunes_explicit(self, itunes_explicit=None): - '''Get or the the itunes:explicit value of the podcast episode. This tag + """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". @@ -435,7 +435,7 @@ def itunes_explicit(self, itunes_explicit=None): as blank. :type itunes_explicit: str :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 "%s" for explicit tag' % itunes_explicit) @@ -443,7 +443,7 @@ def itunes_explicit(self, itunes_explicit=None): 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 + """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”. @@ -451,13 +451,13 @@ def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None): :param itunes_is_closed_captioned: If the episode has closed captioning support. :type itunes_is_closed_captioned: bool or str :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 + """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 @@ -472,26 +472,26 @@ def itunes_order(self, itunes_order=None): :param itunes_order: The order of the episode. :type itunes_order: int :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 + """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. :type itunes_subtitle: str :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 + """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 @@ -501,7 +501,7 @@ def itunes_summary(self, itunes_summary=None): :param itunes_summary: Summary of the podcast episode. :type itunes_summary: str :returns: Summary of the podcast episode. - ''' + """ if not itunes_summary is None: self.__itunes_summary = itunes_summary return self.__itunes_summary diff --git a/feedgen/util.py b/feedgen/util.py index 5065759..50e644c 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -''' +""" feedgen.util ~~~~~~~~~~~~ @@ -7,12 +7,12 @@ :copyright: 2013, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. -''' +""" import sys, locale 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 + """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. @@ -22,7 +22,7 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): :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: @@ -63,8 +63,8 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): def formatRFC2822(d): - '''Make sure the locale setting do not interfere with the time format. - ''' + """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') diff --git a/feedgen/version.py b/feedgen/version.py index e476f36..d278644 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -''' +""" feedgen.version ~~~~~~~~~~~~~~~ @@ -7,7 +7,7 @@ :license: FreeBSD and LGPL, see license.* for more details. -''' +""" 'Version of python-feedgen represented as tuple' version = (0, 3, 2) From c1beb9ee4fe5ee6402395ea2a096863e77536eae Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 20:36:51 +0200 Subject: [PATCH 022/200] Fix doc-generation trying to copy feedgen/ext A leftover from commit b41bfea in which support for extensions was removed. The Makefile would still try to copy documentation made for extensions, which caused an error. --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 8c6e697..4f087d9 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,6 @@ doc-html: @cp doc/_build/html/*.html docs/html/ @cp doc/_build/html/*.js docs/html/ @cp -r doc/_build/html/_static/ docs/html/ - @cp -r doc/_build/html/ext/ docs/html/ doc-man: @echo 'Generating manpage' From 8335fb28b73a5d6e6b3f7c122d9f89d2720fdccd Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 20:39:40 +0200 Subject: [PATCH 023/200] Fix conf.py not supporting Python3 division behaviour --- doc/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 087d6e5..b01ffc4 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -262,7 +262,7 @@ def process_docstring(app, what, name, obj, options, lines): ll = [] for l in lines: spacelen = len(spaces_pat.search(l).group(0)) - newlen = (spacelen % 8) + (spacelen / 8 * 4) + newlen = int((spacelen % 8) + (spacelen / 8 * 4)) ll.append( (' '*newlen) + l.lstrip(' ') ) lines[:] = ll From da968394b653f0f49200a18de597c1cdbe148b3d Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 20:41:04 +0200 Subject: [PATCH 024/200] Make add_episode easier to understand --- feedgen/feed.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index ad7754a..f5ad110 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -768,12 +768,13 @@ def itunes_subtitle(self, itunes_subtitle=None): return self.__itunes_subtitle - def add_episode(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. + def add_episode(self, new_episode=None): + """Shorthand method which adds a new episode to the feed, creating an + object if it's not provided, and returns it. This + is the easiest way to add episodes to a podcast. - :param feedEntry: Episode object to add. + :param new_episode: Episode object to add. A new instance of + self.Episode is used if new_episode is omitted. :returns: Episode object created or passed to this function. Example:: @@ -781,12 +782,21 @@ def add_episode(self, feedEntry=None): ... >>> entry = feedgen.add_episode() >>> entry.title('First feed entry') + 'First feed entry' + >>> # You may also provide an episode object yourself: + >>> another_entry = feedgen.add_episode(feedgen.Episode()) + >>> another_entry.title('My second feed entry') + 'My second feed entry' - """ - if feedEntry is None: - feedEntry = self.Episode() + For the curious, this is a shorthand method which basically reads like:: - version = sys.version_info[0] + if new_episode is None: + new_episode = self.Episode() + self.episodes.append(new_episode) + return new_episode - self.episodes.append(feedEntry) - return feedEntry + """ + if new_episode is None: + new_episode = self.Episode() + self.episodes.append(new_episode) + return new_episode From 81c4a8be02f4583950a61c0438ed8d25806fa9e7 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 20:57:17 +0200 Subject: [PATCH 025/200] Move add_episode method up to its related methods --- feedgen/feed.py | 67 ++++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index f5ad110..9bc51df 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -135,6 +135,39 @@ def Episode(self, value): else: raise ValueError("New Episode must be Episode or a descendant of it (so the API still works).") + def add_episode(self, new_episode=None): + """Shorthand method which adds a new episode to the feed, creating an + object if it's not provided, and returns it. This + is the easiest way to add episodes to a podcast. + + :param new_episode: Episode object to add. A new instance of + self.Episode is used if new_episode is omitted. + :returns: Episode object created or passed to this function. + + Example:: + + ... + >>> entry = feedgen.add_episode() + >>> entry.title('First feed entry') + 'First feed entry' + >>> # You may also provide an episode object yourself: + >>> another_entry = feedgen.add_episode(feedgen.Episode()) + >>> another_entry.title('My second feed entry') + 'My second feed entry' + + For the curious, this is a shorthand method which basically reads like:: + + if new_episode is None: + new_episode = self.Episode() + self.episodes.append(new_episode) + return new_episode + + """ + if new_episode is None: + new_episode = self.Episode() + self.episodes.append(new_episode) + return new_episode + def _create_rss(self): """Create an RSS feed xml structure containing all previously set fields. @@ -766,37 +799,3 @@ def itunes_subtitle(self, itunes_subtitle=None): if not itunes_subtitle is None: self.__itunes_subtitle = itunes_subtitle return self.__itunes_subtitle - - - def add_episode(self, new_episode=None): - """Shorthand method which adds a new episode to the feed, creating an - object if it's not provided, and returns it. This - is the easiest way to add episodes to a podcast. - - :param new_episode: Episode object to add. A new instance of - self.Episode is used if new_episode is omitted. - :returns: Episode object created or passed to this function. - - Example:: - - ... - >>> entry = feedgen.add_episode() - >>> entry.title('First feed entry') - 'First feed entry' - >>> # You may also provide an episode object yourself: - >>> another_entry = feedgen.add_episode(feedgen.Episode()) - >>> another_entry.title('My second feed entry') - 'My second feed entry' - - For the curious, this is a shorthand method which basically reads like:: - - if new_episode is None: - new_episode = self.Episode() - self.episodes.append(new_episode) - return new_episode - - """ - if new_episode is None: - new_episode = self.Episode() - self.episodes.append(new_episode) - return new_episode From f7585d7959cee0562abd72620430aaf7e5c55d49 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 20:57:52 +0200 Subject: [PATCH 026/200] Improve description of Podcast.Episode --- feedgen/feed.py | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 9bc51df..e4237d0 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -82,44 +82,55 @@ def episodes(self): @property def Episode(self): - """The class that is used by self.add_episode() when creating new episode objects. + """Class used to represent episodes. - Defaults to BaseEpisode, at least in Podcast. + This is actually a property (variable) which points to the correct + class. It is used by :py:meth:`.add_episode` when creating new episode + objects, and you should use it too when adding episodes. - When assigning a new value to Episode, you must make sure the new value + By default, this property points to :py:class:`BaseEpisode`. + + When assigning a new class to Episode, you must make sure the new value (1) is a class and not an instance, and (2) is a subclass of BaseEpisode - (or is Episode itself). + (or is BaseEpisode itself). This property exists so you can change which class episodes should have, without needing to change the code that creates those episodes. Thus, changing this property changes what class is used by self.add_episode(). An example would be if you created a subclass of Podcast together with a subclass of Episode, and wanted users of your new Podcast subclass to be using your new Episode subclass automatically. All you need to do, is to change the initial value of Episode in your Podcast subclass. - Users, on the other hand, won't have to change their code when changing between different + Another example is if you want to use another class for episodes, while + still enjoying the benefits of using :py:meth:`.add_episode`. + You as a users, on the other hand, won't have to change your code when changing between different subclasses of Podcast that expect different subclasses of Episode. - It is still possible for users to hardcode what Episode subclass they want to use, either by calling its - constructor without using Episode, or by overriding the initial value of Episode. + It is still possible for you to hardcode what Episode subclass you want to use, either by calling its + constructor without using this property, or by overriding its value. Example of use:: >>> # Create new podcast >>> from feedgen.feed import Podcast >>> p = Podcast() - >>> + >>> # Here's how you would create a new episode object, the OK way >>> episode1 = p.Episode() >>> p.episodes.append(episode1) >>> episode1.title("My awesome episode") - >>> + >>> # Best way to create new episode object (it is added to the podcast automatically) >>> episode2 = p.add_episode() >>> episode2.title("My even more awesome episode") - >>> + + >>> # If you want to use another class for episodes, do it like this + >>> from mymodule import AlternateEpisode + >>> p.Episode = AlternateEpisode + >>> episode3 = p.add_episode() # It is also okay to use p.episodes + >>> episode3.title("This is an instance of AlternateEpisode!") + >>> # !!! DON'T DO THE FOLLOWING, unless you want to hard code what class is used !!! - >>> from feedgen.item import BaseEpisode - >>> episode3 = BaseEpisode() - >>> p.episodes.append(episode3) + >>> episode3 = AlternateEpisode() + >>> p.episodes.append(episode3) # or p.add_episode(episode3) >>> episode3.title("My awful episode :(") """ return self.__episode_class From 13dfec0d73970883d3f58ebf4e908861e604d56b Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 21:10:12 +0200 Subject: [PATCH 027/200] Change return type of _create_rss, to ease extending --- feedgen/feed.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index e4237d0..f8dca37 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -180,9 +180,10 @@ def add_episode(self, new_episode=None): return new_episode def _create_rss(self): - """Create an RSS feed xml structure containing all previously set fields. + """Create an RSS feed XML structure containing all previously set fields. - :returns: Tuple containing the feed root element and the element tree. + :returns: The root element (ie. the rss element) of the feed. + :rtype: lxml.etree.Element """ nsmap = { @@ -309,8 +310,7 @@ def _create_rss(self): item = entry.rss_entry() channel.append(item) - doc = etree.ElementTree(feed) - return feed, doc + return feed def rss_str(self, pretty=False, encoding='UTF-8', @@ -327,7 +327,7 @@ def rss_str(self, pretty=False, encoding='UTF-8', :type xml_declaration: bool :returns: String representation of the RSS feed. """ - feed, doc = self._create_rss() + feed = self._create_rss() return etree.tostring(feed, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) @@ -347,7 +347,8 @@ def rss_file(self, filename, pretty=False, output (Default: enabled). :type xml_declaration: bool """ - feed, doc = self._create_rss() + feed = self._create_rss() + doc = etree.ElementTree(feed) doc.write(filename, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) From 129dcd4f416b23ff4f800a3aa32f19cce568afb0 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 21:41:00 +0200 Subject: [PATCH 028/200] Make Podcast.rss_str return str, not bytes --- feedgen/feed.py | 2 +- feedgen/tests/test_entry.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index f8dca37..206aed5 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -329,7 +329,7 @@ def rss_str(self, pretty=False, encoding='UTF-8', """ feed = self._create_rss() return etree.tostring(feed, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) + xml_declaration=xml_declaration).decode(encoding) def rss_file(self, filename, pretty=False, diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 746a2ee..bcb3143 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -89,4 +89,4 @@ def test_categoryHasDomain(self): }]) result = fg.rss_str() - assert b'domain="http://www.somedomain.com/category"' in result + assert 'domain="http://www.somedomain.com/category"' in result From 73351c1ef064ff9c711f9246583009023ac6c698 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 21:50:39 +0200 Subject: [PATCH 029/200] Add __str__ method which serializes the podcast as RSS --- feedgen/feed.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/feedgen/feed.py b/feedgen/feed.py index 206aed5..13a3b58 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -312,6 +312,12 @@ def _create_rss(self): return feed + def __str__(self): + """Print the podcast in RSS format, using the default options. + + This method just calls :py:meth:`.rss_str` without arguments. + """ + return self.rss_str() def rss_str(self, pretty=False, encoding='UTF-8', xml_declaration=True): From 4843a8656d8ccec78493cef7aa48ca9d5b6abba2 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 22:00:45 +0200 Subject: [PATCH 030/200] Remove pretty, add minimize parameter to rss methods I think RSS feeds should be pretty by default, since it enhances readability for people who are curious on what the XML looks like. This doesn't increase the size of the XML files that much if you compress with gzip. If you want to conserve space and save transfer times, set minimize to True. --- feedgen/__init__.py | 8 ++++---- feedgen/__main__.py | 6 +++--- feedgen/feed.py | 32 +++++++++++++++++--------------- feedgen/tests/test_feed.py | 4 ++-- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index bfd47f2..fbbfed1 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -65,8 +65,8 @@ After that you can generate RSS by calling:: - >>> rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string - >>> fg.rss_file('rss.xml') # Write the RSS feed to a file + >>> rssfeed = fg.rss_str() # Get the RSS feed as string + >>> fg.rss_file('rss.xml', minimize=True) # Write the RSS feed to a file ---------------- @@ -104,8 +104,8 @@ >>> 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() + >>> fg.rss_file('podcast.xml', minimize=True) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index cb115b3..d619993 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -60,7 +60,7 @@ def print_enc(s): fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) if arg == 'rss': - print_enc(fg.rss_str(pretty=True)) + print_enc(fg.rss_str()) elif arg == 'podcast': fg.itunes_author('Lars Kiesow') fg.itunes_category('Technology', 'Podcasting') @@ -70,6 +70,6 @@ def print_enc(s): fg.itunes_owner('John Doe', 'john@example.com') fe.itunes_author('Lars Kiesow') fe.pubdate(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) - print_enc(fg.rss_str(pretty=True)) + print_enc(fg.rss_str()) elif arg.endswith('rss'): - fg.rss_file(arg) + fg.rss_file(arg, minimize=True) diff --git a/feedgen/feed.py b/feedgen/feed.py index 13a3b58..ca8566d 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -319,14 +319,15 @@ def __str__(self): """ return self.rss_str() - def rss_str(self, pretty=False, encoding='UTF-8', - xml_declaration=True): + def rss_str(self, minimize=False, 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. - :type pretty: bool - :param encoding: Encoding used in the XML file (default: UTF-8). + :param minimize: Set to True to disable splitting the feed into multiple + lines and adding properly indentation, saving bytes at the cost of + readability. + :type minimize: bool + :param encoding: Encoding used in the XML file (default: UTF-8). :type encoding: str :param xml_declaration: If an XML declaration should be added to the output (Default: enabled). @@ -334,19 +335,20 @@ def rss_str(self, pretty=False, encoding='UTF-8', :returns: String representation of the RSS feed. """ feed = self._create_rss() - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration).decode(encoding) + return etree.tostring(feed, pretty_print=not minimize, encoding=encoding, + xml_declaration=xml_declaration).decode(encoding) - def rss_file(self, filename, pretty=False, - encoding='UTF-8', xml_declaration=True): + def rss_file(self, filename, minimize=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. :type filename: str or fd - :param pretty: If the feed should be split into multiple lines and - properly indented. - :type pretty: bool + :param minimize: Set to True to disable splitting the feed into multiple + lines and adding properly indentation, saving bytes at the cost of + readability. + :type minimize: bool :param encoding: Encoding used in the XML file (default: UTF-8). :type encoding: str :param xml_declaration: If an XML declaration should be added to the @@ -355,8 +357,8 @@ def rss_file(self, filename, pretty=False, """ feed = self._create_rss() doc = etree.ElementTree(feed) - doc.write(filename, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) + doc.write(filename, pretty_print=not minimize, encoding=encoding, + xml_declaration=xml_declaration) def title(self, title=None): diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 0cf30d7..73cc368 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -79,7 +79,7 @@ def test_baseFeed(self): def test_rssFeedFile(self): fg = self.fg filename = 'tmp_Rssfeed.xml' - fg.rss_file(filename=filename, pretty=True, xml_declaration=False) + fg.rss_file(filename=filename, xml_declaration=False) with open (filename, "r") as myfile: rssString=myfile.read().replace('\n', '') @@ -88,7 +88,7 @@ def test_rssFeedFile(self): def test_rssFeedString(self): fg = self.fg - rssString = fg.rss_str(pretty=True, xml_declaration=False) + rssString = fg.rss_str(xml_declaration=False) self.checkRssString(rssString) From cf9d7b637dfcc78aa088394cfc98e8d182ce3630 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 22:34:42 +0200 Subject: [PATCH 031/200] Remove little used properties from Episode category and comments were removed, as well as the pubdate alias for published. --- feedgen/__main__.py | 2 +- feedgen/feed.py | 2 +- feedgen/item.py | 68 ------------------------------------- feedgen/tests/test_entry.py | 16 --------- 4 files changed, 2 insertions(+), 86 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index d619993..c222cae 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -69,7 +69,7 @@ def print_enc(s): fg.itunes_new_feed_url('http://example.com/new-feed.rss') fg.itunes_owner('John Doe', 'john@example.com') fe.itunes_author('Lars Kiesow') - fe.pubdate(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) + fe.published(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) print_enc(fg.rss_str()) elif arg.endswith('rss'): fg.rss_file(arg, minimize=True) diff --git a/feedgen/feed.py b/feedgen/feed.py index ca8566d..1a52364 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -239,7 +239,7 @@ def _create_rss(self): managingEditor.text = self.__rss_managingEditor if not self.__rss_pubDate: - episode_dates = [e.pubdate() for e in self.episodes if e.pubdate() is not None] + episode_dates = [e.published() for e in self.episodes if e.published() is not None] if episode_dates: actual_pubDate = max(episode_dates) else: diff --git a/feedgen/item.py b/feedgen/item.py index 4fa7b35..b998764 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -28,15 +28,12 @@ class BaseEpisode(object): def __init__(self): # 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 # ITunes tags @@ -83,14 +80,6 @@ def rss_entry(self, extensions=True): 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'] @@ -245,43 +234,6 @@ def description(self, description=None): self.__rss_description = description return self.__rss_description - 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. - - 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.__rss_category is None: - self.__rss_category = [] - if isinstance(category, collections.Mapping): - category = [category] - for cat in category: - rss_cat = dict() - 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.__rss_category - - 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. @@ -305,26 +257,6 @@ def published(self, published=None): return self.__rss_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. - """ - return self.published(pubDate) - - - def comments(self, comments=None): - """Get or set the the value of comments which is the url of the comments - page for the item. - - :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 this item. diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index bcb3143..8d1c3e1 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -74,19 +74,3 @@ def test_removeEntryByEntry(self): assert len(fg.episodes) == 1 fg.episodes.remove(fe) assert len(fg.episodes) == 0 - - def test_categoryHasDomain(self): - fg = Podcast() - fg.title('some title') - fg.link( href='http://www.dontcare.com') - fg.description('description') - fe = fg.add_episode() - fe.guid('http://lernfunk.de/media/654321/1') - fe.title('some title') - fe.category([ - {'term' : 'category', - 'scheme': 'http://www.somedomain.com/category', - }]) - - result = fg.rss_str() - assert 'domain="http://www.somedomain.com/category"' in result From 08b7693854d5da701a2a6949251f77d7049b73fa Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 24 Jun 2016 23:54:16 +0200 Subject: [PATCH 032/200] Combine BaseEpisode.content, description and itunes:summary into summary All the three properties content, description and itunes:summary serve the same purpose. They are therefore combined into one property called summary. The very ambigious argument "type" is renamed to the boolean "html", which indicates whether the summary is safe for HTML-parsing. Either way, CDATA is used for both description and content. The description is duplicated to ensure compatibility with all clients. iTunes uses description when itunes:summary is not present, so itunes:summary is omitted. --- feedgen/__main__.py | 5 ++- feedgen/item.py | 80 ++++++++++++++------------------------------- feedgen/util.py | 15 +++++++++ 3 files changed, 41 insertions(+), 59 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index c222cae..5d07fbe 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -49,13 +49,12 @@ def print_enc(s): fe = fg.add_episode() fe.guid('http://lernfunk.de/_MEDIAID_123#1') fe.title('First Element') - fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen + fe.summary('''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.description(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…') + verba <3.''', html=False) fe.link( href='http://example.com') fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) diff --git a/feedgen/item.py b/feedgen/item.py index b998764..84a15bc 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -13,7 +13,7 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.util import ensure_format, formatRFC2822 +from feedgen.util import ensure_format, formatRFC2822, htmlencode from feedgen.compat import string_types @@ -28,7 +28,6 @@ class BaseEpisode(object): def __init__(self): # RSS self.__rss_author = None - self.__rss_description = None self.__rss_content = None self.__rss_enclosure = None self.__rss_guid = None @@ -46,13 +45,12 @@ def __init__(self): self.__itunes_is_closed_captioned = None self.__itunes_order = None self.__itunes_subtitle = None - self.__itunes_summary = None 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): + if not ( self.__rss_title or self.__rss_content): raise ValueError('Required fields not set') if self.__rss_title: title = etree.SubElement(entry, 'title') @@ -60,19 +58,12 @@ def rss_entry(self, extensions=True): if self.__rss_link: link = etree.SubElement(entry, 'link') link.text = self.__rss_link - if self.__rss_description and self.__rss_content: + if self.__rss_content: description = etree.SubElement(entry, 'description') - description.text = self.__rss_description + description.text = etree.CDATA(self.__rss_content) 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'] + content.text = etree.CDATA(self.__rss_content) for a in self.__rss_author or []: author = etree.SubElement(entry, 'author') author.text = a @@ -124,10 +115,6 @@ def rss_entry(self, extensions=True): 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 @@ -196,20 +183,27 @@ def author(self, author=None, replace=False, **kwargs): return self.__rss_author - def content(self, content=None, type=None): - """Get or set the content of the entry which contains or links to the - complete content of the entry. If the content is set (not linked) it will also set - rss:description. + def summary(self, new_summary=None, html=True): + """Get or set the summary of this episode. + + In iTunes, the summary is shown in a separate window that appears when + the "circled i" in the Description column is clicked. This field can be + up to 4000 characters in length. + + See also :py:meth:`.itunes_subtitle`. - :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. + :param new_summary: The summary of this episode. + :param html: Treat the summary as HTML. If set to False, the summary + will be HTML escaped (thus, any tags will be displayed in plain + text). If set to True, the tags are parsed by clients which support + HTML, but if something is not to be regarded as HTML, you must + escape it yourself using HTML entities. + :returns: Summary of this episode. """ - if not content is None: - self.__rss_content = {'content':content} - if not type is None: - self.__rss_content['type'] = type + if not new_summary is None: + if not html: + new_summary = htmlencode(new_summary) + self.__rss_content = new_summary return self.__rss_content @@ -224,16 +218,6 @@ def link(self, href=None): return self.__rss_link - def description(self, description=None): - """Get or set the description value which is the item synopsis. - - :param description: Description of the entry. - :returns: The entries description. - """ - if not description is None: - self.__rss_description = description - return self.__rss_description - 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. @@ -421,19 +405,3 @@ def itunes_subtitle(self, itunes_subtitle=None): 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. - :type itunes_summary: str - :returns: Summary of the podcast episode. - """ - if not itunes_summary is None: - self.__itunes_summary = itunes_summary - return self.__itunes_summary diff --git a/feedgen/util.py b/feedgen/util.py index 50e644c..a3c7661 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -70,3 +70,18 @@ def formatRFC2822(d): d = d.strftime('%a, %d %b %Y %H:%M:%S %z') locale.setlocale(locale.LC_ALL, l) return d + +# Define htmlencode +ver = sys.version_info + +if ver < (3, 2): + # cgi.escape was deprecated in 3.2 + import cgi + + def htmlencode(s): + return cgi.escape(s, quote=True) +else: + import html + + def htmlencode(s): + return html.escape(s) From a14d43b7f78f41b62f8bda452472f29a358fc5f1 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 25 Jun 2016 13:30:24 +0200 Subject: [PATCH 033/200] Rename guid to id, use enclosure url if id not set The guid property is renamed to the more simple name 'id'. The fact that this is a _globally unique_ identifier is stressed in the documentation. Furthermore, if you don't set the id but have set an enclosure, then the enclosure's url is used as the id. This is consistent with how most feeds do things. A warning about this behaviour requiring the url to be permanent is added to the documentation. You can set id to False or an empty string to suppress the behaviour of using the enclosure url, resulting in an entry without a guid element. --- feedgen/__init__.py | 4 +-- feedgen/__main__.py | 2 +- feedgen/item.py | 58 +++++++++++++++++++++++++++++++------ feedgen/tests/test_entry.py | 37 +++++++++++++++++++---- readme.md | 4 +-- 5 files changed, 86 insertions(+), 19 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index fbbfed1..4ced448 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -79,7 +79,7 @@ BaseEpisode object:: >>> fe = fg.add_episode() - >>> fe.guid('http://lernfunk.de/media/654321/1') + >>> fe.id('http://lernfunk.de/media/654321/1') >>> fe.title('The First Episode') The Podcast method `add_episode(...)` without argument provides will @@ -99,7 +99,7 @@ >>> fg.itunes_category('Technology', 'Podcasting') ... >>> fe = fg.add_episode() - >>> fe.guid('http://lernfunk.de/media/654321/1/file.mp3') + >>> 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') diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 5d07fbe..6f5cc6a 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -47,7 +47,7 @@ def print_enc(s): fg.description('This is a cool feed!') fg.language('de') fe = fg.add_episode() - fe.guid('http://lernfunk.de/_MEDIAID_123#1') + fe.id('http://lernfunk.de/_MEDIAID_123#1') fe.title('First Element') fe.summary('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si ista diff --git a/feedgen/item.py b/feedgen/item.py index 84a15bc..8253306 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -15,6 +15,7 @@ import dateutil.tz from feedgen.util import ensure_format, formatRFC2822, htmlencode from feedgen.compat import string_types +from builtins import str class BaseEpisode(object): @@ -50,14 +51,18 @@ def __init__(self): 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_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_content: description = etree.SubElement(entry, 'description') description.text = etree.CDATA(self.__rss_content) @@ -67,15 +72,25 @@ 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: + rss_guid = self.__rss_guid + elif self.__rss_enclosure and self.__rss_guid is None: + rss_guid = self.__rss_enclosure['url'] + else: + # self.__rss_guid was set to boolean False, or no enclosure + rss_guid = None + if rss_guid: guid = etree.SubElement(entry, 'guid') - guid.text = self.__rss_guid + guid.text = rss_guid guid.attrib['isPermaLink'] = 'false' + 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['length'] = str(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) @@ -131,15 +146,31 @@ def title(self, title=None): return self.__rss_title - def guid(self, guid=None): - """Get or set the entries guid which is a string that uniquely identifies - the item. + def id(self, new_id=None): + """Get or set this episode's globally unique identifier. + + If not present, the URL of the enclosed media is used. Set the id to + boolean False to suppress this behaviour. + + It is important that an episode keeps the same ID until the end of time, + since the ID is used by clients to identify which episodes have been + listened to, which episodes are new, and so on. Changing the ID causes + the same consequences as deleting the existing episode and adding a + new, identical episode. - :param guid: Id of the entry. - :returns: Id of the entry. + Note that this is a GLOBALLY unique identifier. Thus, not only must it + be unique in this podcast, it must not be the same ID as any other + episode for any podcast out there. To ensure this, you should use a + domain which you own (for example, use something like + http://example.org/podcast/episode1.mp3 if you own example.org). + + This property corresponds to the RSS GUID element. + + :param new_id: Globally unique, permanent id of this episode. + :returns: Id of this episode. """ - if not guid is None: - self.__rss_guid = guid + if not new_id is None: + self.__rss_guid = new_id return self.__rss_guid @@ -245,6 +276,15 @@ 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 this item. + Note that if :py:meth:`.id` is not set, the enclosure's url is used as + the globally unique identifier. If you rely on this, you should make + sure the url never changes, since changing the id messes up with clients + (they will think this episode is new again, even if the user already + has listened to it). Therefore, you should only rely on this behaviour + if you own the domain which the episodes reside on. If you don't, then + you must set :py:meth:`.id` to something which is unique not only for + this podcast, but for all podcasts. + :param url: URL of the media object. :param length: Size of the media in bytes. :param type: Mimetype of the linked media. diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 8d1c3e1..2cc067e 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -20,17 +20,17 @@ def setUp(self): fg.title(self.title) fe = fg.add_episode() - fe.guid('http://lernfunk.de/media/654321/1') + fe.id('http://lernfunk.de/media/654321/1') fe.title('The First Episode') #Use also the list directly fe = fg.Episode() fg.episodes.append(fe) - fe.guid('http://lernfunk.de/media/654321/1') + fe.id('http://lernfunk.de/media/654321/1') fe.title('The Second Episode') fe = fg.add_episode() - fe.guid('http://lernfunk.de/media/654321/1') + fe.id('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') self.fg = fg @@ -56,7 +56,7 @@ def test_removeEntryByIndex(self): self.title = 'Some Testfeed' fe = fg.add_episode() - fe.guid('http://lernfunk.de/media/654321/1') + fe.id('http://lernfunk.de/media/654321/1') fe.title('The Third BaseEpisode') assert len(fg.episodes) == 1 fg.episodes.pop(0) @@ -68,9 +68,36 @@ def test_removeEntryByEntry(self): self.title = 'Some Testfeed' fe = fg.add_episode() - fe.guid('http://lernfunk.de/media/654321/1') + fe.id('http://lernfunk.de/media/654321/1') fe.title('The Third BaseEpisode') assert len(fg.episodes) == 1 fg.episodes.remove(fe) assert len(fg.episodes) == 0 + + def test_idIsSet(self): + guid = "http://example.com/podcast/episode1" + episode = self.fg.Episode() + episode.title("My first episode") + episode.id(guid) + item = episode.rss_entry() + + assert item.find("guid").text == guid + + def test_idNotSetButEnclosureIsUsed(self): + guid = "http://example.com/podcast/episode1.mp3" + episode = self.fg.Episode() + episode.title("My first episode") + episode.enclosure(guid, 0, "audio/mpeg") + + item = episode.rss_entry() + assert item.find("guid").text == guid + + def test_idSetToFalseSoEnclosureNotUsed(self): + episode = self.fg.Episode() + episode.title("My first episode") + episode.enclosure("http://example.com/podcast/episode1.mp3", 0, "audio/mpeg") + episode.id(False) + + item = episode.rss_entry() + assert item.find("guid") is None diff --git a/readme.md b/readme.md index fa16a87..fc05cbf 100644 --- a/readme.md +++ b/readme.md @@ -79,7 +79,7 @@ way to go is to use the Podcast itself for the instantiation of the BaseEpisode object: >>> fe = fg.add_episode() - >>> fe.guid('http://lernfunk.de/media/654321/1') + >>> fe.id('http://lernfunk.de/media/654321/1') >>> fe.title('The First BaseEpisode') The FeedGenerators method `add_episode(...)` without argument provides will @@ -99,7 +99,7 @@ although most features are platform agnostic. >>> fg.itunes_category('Technology', 'Podcasting') ... >>> fe = fg.add_episode() - >>> fe.guid('http://lernfunk.de/media/654321/1/file.mp3') + >>> 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') From 6319736d4a0deac717ac9b67d3400af32c54debd Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 12:13:23 +0200 Subject: [PATCH 034/200] Add atom:link with rel=self, per recommendations You can now set a feed's feed_url, which makes an atom:link element with rel=self appear, per the RSS recommendations. --- feedgen/__main__.py | 1 + feedgen/feed.py | 32 ++++++++++++++++++++++++++++++++ feedgen/tests/test_feed.py | 12 ++++++++++++ 3 files changed, 45 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 6f5cc6a..b49adcc 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -46,6 +46,7 @@ def print_enc(s): fg.copyright('cc-by') fg.description('This is a cool feed!') fg.language('de') + fg.feed_url('http://example.com/feeds/myfeed.rss') fe = fg.add_episode() fe.id('http://lernfunk.de/_MEDIAID_123#1') fe.title('First Element') diff --git a/feedgen/feed.py b/feedgen/feed.py index 1a52364..4cbf6bd 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -56,6 +56,8 @@ def __init__(self): self.__rss_skipDays = None self.__rss_webMaster = None + self.__self_link = None + ## ITunes tags # http://www.apple.com/itunes/podcasts/specs.html#rss self.__itunes_author = None @@ -306,6 +308,12 @@ def _create_rss(self): subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) subtitle.text = self.__itunes_subtitle + if self.__self_link: + link_to_self = etree.SubElement(channel, '{%s}link' % nsmap['atom']) + link_to_self.attrib['href'] = self.__self_link + link_to_self.attrib['rel'] = 'self' + link_to_self.attrib['type'] = 'application/rss+xml' + for entry in self.episodes: item = entry.rss_entry() channel.append(item) @@ -819,3 +827,27 @@ def itunes_subtitle(self, itunes_subtitle=None): if not itunes_subtitle is None: self.__itunes_subtitle = itunes_subtitle return self.__itunes_subtitle + + def feed_url(self, feed_url=None): + """Get or set the URL which this feed is available at. + + Identifying a feed's URL within the feed makes it more portable, + self-contained, and easier to cache. You should therefore set this + property, if you're able to. + + :param feed_url: The URL at which you can access this feed. + :type feed_url: str + :returns: The URL at which you can access this feed. + """ + if feed_url is not None: + if feed_url and not feed_url.startswith(( + 'http://', + 'https://', + 'ftp://', + 'news://')): + raise ValueError("The feed url must be a valid URL, but it " + "doesn't have a valid URL scheme " + "(like for example http:// or https://)") + self.__self_link = feed_url + return self.__self_link + diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 73cc368..0548c93 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -18,6 +18,7 @@ def setUp(self): fg = Podcast() self.nsRss = "http://purl.org/rss/1.0/modules/content/" + self.feedUrl = "http://example.com/feeds/myfeed.rss" self.title = 'Some Testfeed' @@ -57,6 +58,7 @@ def setUp(self): fg.skipDays(self.skipDays) fg.skipHours(self.skipHours) fg.webMaster(self.webMaster) + fg.feed_url(self.feedUrl) self.fg = fg @@ -74,6 +76,7 @@ def test_baseFeed(self): assert fg.description() == self.description assert fg.language() == self.language + assert fg.feed_url() == self.feedUrl def test_rssFeedFile(self): @@ -96,6 +99,7 @@ def checkRssString(self, rssString): feed = etree.fromstring(rssString) nsRss = self.nsRss + nsAtom = "http://www.w3.org/2005/Atom" channel = feed.find("channel") assert channel != None @@ -116,6 +120,14 @@ def checkRssString(self, rssString): assert channel.find("skipDays").find("day").text == self.skipDays assert int(channel.find("skipHours").find("hour").text) == self.skipHours assert channel.find("webMaster").text == self.webMaster + assert channel.find("{%s}link" % nsAtom).get('href') == self.feedUrl + assert channel.find("{%s}link" % nsAtom).get('rel') == 'self' + assert channel.find("{%s}link" % nsAtom).get('type') == \ + 'application/rss+xml' + + def test_feedUrlValidation(self): + self.assertRaises(ValueError, self.fg.feed_url, "example.com/feed.rss") + if __name__ == '__main__': unittest.main() From 47ce5dbfa65d19710396df9734b6364c88cec09f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 13:20:46 +0200 Subject: [PATCH 035/200] Rename Podcast.link to Podcast.website --- feedgen/__init__.py | 4 ++-- feedgen/__main__.py | 2 +- feedgen/feed.py | 11 ++++++----- feedgen/tests/test_feed.py | 4 ++-- readme.md | 4 ++-- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 4ced448..e6bcef5 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -40,10 +40,10 @@ >>> fg = Podcast() >>> fg.title('Some Testfeed') >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) - >>> fg.link( href='http://example.com', rel='alternate' ) + >>> fg.website( href='http://example.com', rel='alternate' ) >>> fg.image('http://ex.com/logo.jpg') >>> fg.description('This is a cool feed!') - >>> fg.link( href='http://larskiesow.de/test.atom') + >>> fg.website( href='http://larskiesow.de/test.atom') >>> fg.language('en') Note that for the methods which set fields that can occur more than once in a diff --git a/feedgen/__main__.py b/feedgen/__main__.py index b49adcc..63937ab 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -42,7 +42,7 @@ def print_enc(s): fg = Podcast() fg.title('Testfeed') fg.managingEditor('lkiesow@uos.de (Lars Kiesow)') - fg.link(href='http://example.com') + fg.website(href='http://example.com') fg.copyright('cc-by') fg.description('This is a cool feed!') fg.language('de') diff --git a/feedgen/feed.py b/feedgen/feed.py index 4cbf6bd..1600ebb 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -413,15 +413,16 @@ def updated(self, updated=None): return self.__rss_lastBuildDate - def link(self, href=None): - """Get or set the feed's link (website). + def website(self, href=None): + """Get or set this podcast's website. - :param href: URI of this feed's website. + This corresponds to the RSS link element. + + :param href: URI of this podcast's website. Example:: - >>> feedgen.link( href='http://example.com/') - [{'href':'http://example.com/', 'rel':'self'}] + >>> feedgen.website( href='http://example.com/') """ if not href is None: diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 0548c93..5339eb0 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -46,7 +46,7 @@ def setUp(self): self.webMaster = 'webmaster@example.com' fg.title(self.title) - fg.link( href=self.linkHref) + fg.website(href=self.linkHref) fg.description(self.description) fg.language(self.language) fg.cloud(domain=self.cloudDomain, port=self.cloudPort, @@ -71,7 +71,7 @@ def test_baseFeed(self): assert fg.managingEditor() == self.managingEditor assert fg.webMaster() == self.webMaster - assert fg.link() == self.linkHref + assert fg.website() == self.linkHref assert fg.description() == self.description diff --git a/readme.md b/readme.md index fc05cbf..962a201 100644 --- a/readme.md +++ b/readme.md @@ -40,10 +40,10 @@ data:: >>> fg = Podcast() >>> fg.title('Some Testfeed') >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) - >>> fg.link( href='http://example.com', rel='alternate' ) + >>> fg.website( href='http://example.com', rel='alternate' ) >>> fg.image('http://ex.com/logo.jpg') >>> fg.description('This is a cool feed!') - >>> fg.link( href='http://larskiesow.de/test.atom') + >>> fg.website( href='http://larskiesow.de/test.atom') >>> fg.language('en') Note that for the methods which set fields that can occur more than once in a From 4323637f798f3da66140362c7804f05deb1a250b Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 13:29:46 +0200 Subject: [PATCH 036/200] Rename Podcast.title to Podcast.name --- feedgen/__init__.py | 2 +- feedgen/__main__.py | 2 +- feedgen/feed.py | 18 +++++++++--------- feedgen/tests/test_entry.py | 2 +- feedgen/tests/test_feed.py | 4 ++-- readme.md | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index e6bcef5..9b60307 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -38,7 +38,7 @@ >>> from feedgen.feed import Podcast >>> fg = Podcast() - >>> fg.title('Some Testfeed') + >>> fg.name('Some Testfeed') >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) >>> fg.website( href='http://example.com', rel='alternate' ) >>> fg.image('http://ex.com/logo.jpg') diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 63937ab..7355cc0 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -40,7 +40,7 @@ def print_enc(s): arg = sys.argv[1] fg = Podcast() - fg.title('Testfeed') + fg.name('Testfeed') fg.managingEditor('lkiesow@uos.de (Lars Kiesow)') fg.website(href='http://example.com') fg.copyright('cc-by') diff --git a/feedgen/feed.py b/feedgen/feed.py index 1600ebb..503ef75 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -369,18 +369,18 @@ def rss_file(self, filename, minimize=False, 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 + def name(self, name=None): + """Get or set the name of the podcast. It should be a human + readable title. Often the same as the title of the + associated website. This is mandatory for RSS and must not be blank. - :param title: The new title of the feed. - :type title: str - :returns: The feeds title. + :param name: The new name of the podcast. + :type name: str + :returns: The podcast's name. """ - if not title is None: - self.__rss_title = title + if not name is None: + self.__rss_title = name return self.__rss_title diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 2cc067e..9dc9e52 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -17,7 +17,7 @@ def setUp(self): fg = Podcast() self.title = 'Some Testfeed' - fg.title(self.title) + fg.name(self.title) fe = fg.add_episode() fe.id('http://lernfunk.de/media/654321/1') diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 5339eb0..85616bd 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -45,7 +45,7 @@ def setUp(self): self.webMaster = 'webmaster@example.com' - fg.title(self.title) + fg.name(self.title) fg.website(href=self.linkHref) fg.description(self.description) fg.language(self.language) @@ -66,7 +66,7 @@ def setUp(self): def test_baseFeed(self): fg = self.fg - assert fg.title() == self.title + assert fg.name() == self.title assert fg.managingEditor() == self.managingEditor assert fg.webMaster() == self.webMaster diff --git a/readme.md b/readme.md index 962a201..c985b1f 100644 --- a/readme.md +++ b/readme.md @@ -38,7 +38,7 @@ data:: >>> from feedgen.feed import Podcast >>> fg = Podcast() - >>> fg.title('Some Testfeed') + >>> fg.name('Some Testfeed') >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) >>> fg.website( href='http://example.com', rel='alternate' ) >>> fg.image('http://ex.com/logo.jpg') From 1489ac438585cba7d375935c2f1f650b73a46d4c Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 14:08:34 +0200 Subject: [PATCH 037/200] Run __main__.py when running `make test` --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index 4f087d9..f5b0054 100644 --- a/Makefile +++ b/Makefile @@ -46,4 +46,6 @@ publish: sdist test: python -m unittest feedgen.tests.test_feed python -m unittest feedgen.tests.test_entry + python -m feedgen rss > /dev/null + python -m feedgen podcast > /dev/null @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml From 6cfbbd666dfcb0e87045c6c2c507b8211539346e Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 14:09:55 +0200 Subject: [PATCH 038/200] Remove itunes:summary from Podcast It was just duplicating description, and iTunes will use description if itunes:summary is not there. --- feedgen/feed.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 503ef75..2ea3db9 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -210,8 +210,6 @@ def _create_rss(self): desc = etree.SubElement(channel, 'description') desc.text = self.__rss_description - summary = etree.SubElement(channel, '{%s}summary' % ITUNES_NS) - summary.text = self.__rss_description if self.__rss_cloud: cloud = etree.SubElement(channel, 'cloud') cloud.attrib['domain'] = self.__rss_cloud.get('domain') @@ -479,10 +477,11 @@ def copyright(self, copyright=None): def description(self, description=None): """Set and get the description of the feed, which is a phrase or sentence describing the channel. It is mandatory for - RSS feeds. + RSS feeds, and is shown under the podcast's name on the iTunes store + page. - :param description: Description of the channel. - :returns: Description of the channel. + :param description: Description of the podcast. + :returns: Description of the podcast. """ if not description is None: From c1d7f152273e937cc6f9c9709d58d02d9a94f661 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 14:19:45 +0200 Subject: [PATCH 039/200] Remove Podcast.docs, there's no reason to differ from the default --- feedgen/feed.py | 17 ----------------- feedgen/tests/test_feed.py | 1 - 2 files changed, 18 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 2ea3db9..61874b8 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -489,23 +489,6 @@ def description(self, description=None): return self.__rss_description - def docs(self, docs=None): - """Get or set the docs value of the feed. 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 diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 85616bd..79204d1 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -53,7 +53,6 @@ def setUp(self): path=self.cloudPath, registerProcedure=self.cloudRegisterProcedure, protocol=self.cloudProtocol) fg.copyright(self.copyright) - fg.docs(docs=self.docs) fg.managingEditor(self.managingEditor) fg.skipDays(self.skipDays) fg.skipHours(self.skipHours) From 96f5cc16f78c67fd520130e6d3ab132f54963baa Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 16:06:03 +0200 Subject: [PATCH 040/200] Allow the cloud port to be int --- feedgen/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 61874b8..bc38ee4 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -213,7 +213,7 @@ def _create_rss(self): 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['port'] = str(self.__rss_cloud.get('port')) cloud.attrib['path'] = self.__rss_cloud.get('path') cloud.attrib['registerProcedure'] = self.__rss_cloud.get( 'registerProcedure') From a2973daca76b1a422bc5373baf98477b8071fbe6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 16:06:52 +0200 Subject: [PATCH 041/200] Add section about known bugs, mention escaping Mention that we do not follow the RSS recommendations when escaping characters in plain text. --- readme.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/readme.md b/readme.md index c985b1f..8efaea0 100644 --- a/readme.md +++ b/readme.md @@ -185,3 +185,11 @@ In short, the **original project breaks all the idioms listed in Philosophy**, a fixing it would require changes too big or too dramatic to be applied upstream. Whenever a change _is_ appropriate for upstream, however, we should strive to bring it there, so it can benefit **everyone**. + +---------- +Known bugs +---------- + +* We do not follow the RSS recommendation to encode &, < and > using + hexadecimal character reference (eg. `<`), simply because lxml provides + no documentation on how to do that when using the text property. From f8931033f913e59db3139c85c085931fa468fd26 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 16:08:20 +0200 Subject: [PATCH 042/200] Ensure all parameters of cloud are set Additionally, recommend that "xml-rpc" or "soap" is used, not capitalized versions or "SOAP 1.1". This is per the RSS recommendations. --- feedgen/feed.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index bc38ee4..3c189df 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -438,10 +438,14 @@ def cloud(self, domain=None, port=None, path=None, registerProcedure=None, :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. + :param protocol: Can be either "HTTP-POST", "xml-rpc" or "soap". :returns: Dictionary containing the cloud data. """ if not domain is None: + if not (domain and (port != False) and path and registerProcedure + and protocol): + raise ValueError("All parameters of cloud must be present and" + " not empty.") self.__rss_cloud = {'domain':domain, 'port':port, 'path':path, 'registerProcedure':registerProcedure, 'protocol':protocol} return self.__rss_cloud From cde99b9ff63a3c011704c2ce8cd25abe989ba9f9 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 16:09:58 +0200 Subject: [PATCH 043/200] Mention that the podcast is copyrighted by default Thus including that part of the RSS recommendation. --- feedgen/feed.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 3c189df..a174df8 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -467,9 +467,20 @@ def generator(self, generator=None, version=None, uri=None): def copyright(self, copyright=None): - """Get or set the copyright notice for content in the channel. + """Get or set the copyright notice for content in this podcast. + + This should be human-readable. For example, "Copyright 2016 Example + Radio". + + Note that even if you leave out the copyright notice, your content is + still protected by copyright (unless anything else is indicated), since + you do not need a copyright statement for something to be protected by + copyright. If you intend to put the podcast in public domain or license + it under a Creative Commons license, you should say so in the copyright + notice. :param copyright: The copyright notice. + :type copyright: str :returns: The copyright notice. """ From 6d061030c2acecc6c039fc3f6f56c31eaefe0e18 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 26 Jun 2016 16:13:55 +0200 Subject: [PATCH 044/200] Mention python-feedgen by default when setting custom generator value It can, of course, be turned off. To allow the program name and URL to be easily changed, they can be found in version.py. --- feedgen/feed.py | 27 +++++++++++++++++++++------ feedgen/tests/test_feed.py | 18 +++++++++++++++++- feedgen/version.py | 6 ++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index a174df8..0c94fad 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -47,7 +47,7 @@ def __init__(self): self.__rss_cloud = None self.__rss_copyright = None self.__rss_docs = 'http://www.rssboard.org/rss-specification' - self.__rss_generator = 'python-feedgen' + self.__rss_generator = self._feedgen_generator_str self.__rss_language = None self.__rss_lastBuildDate = datetime.now(dateutil.tz.tzutc()) self.__rss_managingEditor = None @@ -451,20 +451,35 @@ def cloud(self, domain=None, port=None, path=None, registerProcedure=None, return self.__rss_cloud - def generator(self, generator=None, version=None, uri=None): + def generator(self, generator=None, version=None, uri=None, + exclude_feedgen=False): """Get or the generator of the feed which identifies the software used to generate the feed, for debugging and other purposes. :param generator: Software used to create the feed. - :param version: (Optional) Version of the software. + :param version: (Optional) Version of the software, as a tuple. :param uri: (Optional) URI the software can be found. + :param exclude_feedgen: (Optional) Set to True to disable the mentioning + of the python-feedgen library. """ if not generator is None: - self.__rss_generator = generator + \ - (("/" + str(version)) if version is not None else "") + \ - ((" " + uri) if uri else "") + self.__rss_generator = self._program_name_to_str(generator, version, uri) + \ + (" (using %s)" % self._feedgen_generator_str + if not exclude_feedgen else "") return self.__rss_generator + def _program_name_to_str(self, generator=None, version=None, uri=None): + return generator + \ + ((" v" + ".".join([str(i) for i in version])) if version is not None else "") + \ + ((" " + uri) if uri else "") + + @property + def _feedgen_generator_str(self): + return self._program_name_to_str( + feedgen.version.name, + feedgen.version.version_full, + feedgen.version.website + ) def copyright(self, copyright=None): """Get or set the copyright notice for content in this podcast. diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 79204d1..a75e4d7 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 Podcast +import feedgen.version class TestSequenceFunctions(unittest.TestCase): @@ -43,6 +44,8 @@ def setUp(self): self.skipDays = 'Tuesday' self.skipHours = 23 + self.programname = feedgen.version.name + self.webMaster = 'webmaster@example.com' fg.name(self.title) @@ -107,7 +110,7 @@ def checkRssString(self, rssString): assert channel.find("description").text == self.description 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 self.programname in channel.find("generator").text assert channel.find("cloud").get('domain') == self.cloudDomain assert channel.find("cloud").get('port') == self.cloudPort assert channel.find("cloud").get('path') == self.cloudPath @@ -127,6 +130,19 @@ def checkRssString(self, rssString): def test_feedUrlValidation(self): self.assertRaises(ValueError, self.fg.feed_url, "example.com/feed.rss") + def test_generator(self): + software_name = "My Awesome Software" + self.fg.generator(software_name) + rss = self.fg._create_rss() + generator = rss.find("channel").find("generator").text + assert software_name in generator + assert self.programname in generator + + self.fg.generator(software_name, exclude_feedgen=True) + generator = self.fg._create_rss().find("channel").find("generator").text + assert software_name in generator + assert self.programname not in generator + if __name__ == '__main__': unittest.main() diff --git a/feedgen/version.py b/feedgen/version.py index d278644..a6211bf 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -23,3 +23,9 @@ 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]) + +'Name of this project' +name = "python-feedgen (podcastgen)" + +'Website of this project' +website = "https://github.com/tobinus/python-feedgen/tree/podcastgen" From db7fe4e1c2a10f69281028313249836f27f1b975 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 27 Jun 2016 10:10:40 +0200 Subject: [PATCH 045/200] Add test for Podcast.__str__() --- feedgen/tests/test_feed.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index a75e4d7..28e6747 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -143,6 +143,12 @@ def test_generator(self): assert software_name in generator assert self.programname not in generator + def test_str(self): + assert str(self.fg) == self.fg.rss_str( + minimize=False, + encoding="UTF-8", + xml_declaration=True + ) if __name__ == '__main__': unittest.main() From 0103f5905917d57f409c2d95f0c9a06360dfc915 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 27 Jun 2016 11:49:56 +0200 Subject: [PATCH 046/200] Leave out publication date when set to False Create tests for publication date. --- feedgen/feed.py | 9 ++++--- feedgen/tests/test_entry.py | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 0c94fad..7b94c5f 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -238,7 +238,7 @@ def _create_rss(self): managingEditor = etree.SubElement(channel, 'managingEditor') managingEditor.text = self.__rss_managingEditor - if not self.__rss_pubDate: + if self.__rss_pubDate is None: episode_dates = [e.published() for e in self.episodes if e.published() is not None] if episode_dates: actual_pubDate = max(episode_dates) @@ -560,15 +560,18 @@ def published(self, pubDate=None): latest publication date (which may be in the future). If there are no episodes, the publication date is omitted from the feed. + If you want to omit the publication date from the feed, set pubDate + to False. + :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): + if pubDate is not False and not isinstance(pubDate, datetime): raise ValueError('Invalid datetime format') - if pubDate.tzinfo is None: + elif pubDate is not False and pubDate.tzinfo is None: raise ValueError('Datetime object has no timezone info') self.__rss_pubDate = pubDate diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 9dc9e52..e5fedbe 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -9,6 +9,9 @@ import unittest from lxml import etree from ..feed import Podcast +import datetime +import pytz +from dateutil.parser import parse as parsedate class TestSequenceFunctions(unittest.TestCase): @@ -16,8 +19,12 @@ def setUp(self): fg = Podcast() self.title = 'Some Testfeed' + self.link = 'http://lernfunk.de' + self.description = 'A cool tent' fg.name(self.title) + fg.website(self.link) + fg.description(self.description) fe = fg.add_episode() fe.id('http://lernfunk.de/media/654321/1') @@ -101,3 +108,43 @@ def test_idSetToFalseSoEnclosureNotUsed(self): item = episode.rss_entry() assert item.find("guid") is None + + def test_feedPubDateUsesNewestEpisode(self): + self.fg.episodes[0].published( + datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) + ) + self.fg.episodes[1].published( + datetime.datetime(2016, 1, 3, 12, 22, tzinfo=pytz.utc) + ) + self.fg.episodes[2].published( + datetime.datetime(2014, 3, 2, 13, 11, tzinfo=pytz.utc) + ) + rss = self.fg._create_rss() + pubDate = rss.find("channel").find("pubDate") + assert pubDate is not None + parsedPubDate = parsedate(pubDate.text) + assert parsedPubDate == self.fg.episodes[1].published() + + def test_feedPubDateNotOverriddenByEpisode(self): + self.fg.episodes[0].published( + datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) + ) + pubDate = self.fg._create_rss().find("channel").find("pubDate") + # Now it uses the episode's published date + assert pubDate is not None + assert parsedate(pubDate.text) == self.fg.episodes[0].published() + + new_date = datetime.datetime(2016, 1, 2, 3, 4, tzinfo=pytz.utc) + self.fg.published(new_date) + pubDate = self.fg._create_rss().find("channel").find("pubDate") + # Now it uses the custom-set date + assert pubDate is not None + assert parsedate(pubDate.text) == new_date + + def test_feedPubDateDisabled(self): + self.fg.episodes[0].published( + datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) + ) + self.fg.published(False) + pubDate = self.fg._create_rss().find("channel").find("pubDate") + assert pubDate is None # Not found! From cad6d60978874dba81547459301d6ee06f5834f6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 27 Jun 2016 11:51:02 +0200 Subject: [PATCH 047/200] Calculate lastBuildDate when generating, not on construction Earlier, if you had long-living Podcast objects, their default lastBuildDate would not reflect the time they were built, but the time the object was created. --- feedgen/feed.py | 32 +++++++++++++++++++++----------- feedgen/tests/test_feed.py | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 7b94c5f..17cdff6 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -49,7 +49,7 @@ def __init__(self): self.__rss_docs = 'http://www.rssboard.org/rss-specification' self.__rss_generator = self._feedgen_generator_str self.__rss_language = None - self.__rss_lastBuildDate = datetime.now(dateutil.tz.tzutc()) + self.__rss_lastBuildDate = None self.__rss_managingEditor = None self.__rss_pubDate = None self.__rss_skipHours = None @@ -230,10 +230,15 @@ def _create_rss(self): if self.__rss_language: language = etree.SubElement(channel, 'language') language.text = self.__rss_language - if self.__rss_lastBuildDate: + + if self.__rss_lastBuildDate is None: + lastBuildDateDate = datetime.now(dateutil.tz.tzutc()) + else: + lastBuildDateDate = self.__rss_lastBuildDate + if lastBuildDateDate: lastBuildDate = etree.SubElement(channel, 'lastBuildDate') + lastBuildDate.text = formatRFC2822(lastBuildDateDate) - lastBuildDate.text = formatRFC2822(self.__rss_lastBuildDate) if self.__rss_managingEditor: managingEditor = etree.SubElement(channel, 'managingEditor') managingEditor.text = self.__rss_managingEditor @@ -390,23 +395,28 @@ def updated(self, updated=None): datetime.datetime object. In any case it is necessary that the value include timezone information. - This will set both atom:updated and rss:lastBuildDate. + This will set rss:lastBuildDate. Default value If not set, updated has as value the current date and time. + Set this to False to have no updated value in the feed. + :param updated: The modification date. :type updated: str or datetime.datetime :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.__rss_lastBuildDate = updated + if updated is False: + self.__rss_lastBuildDate = False + else: + 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.__rss_lastBuildDate = updated return self.__rss_lastBuildDate diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 28e6747..f412e51 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -11,6 +11,9 @@ from lxml import etree from ..feed import Podcast import feedgen.version +import datetime +import dateutil.tz +import dateutil.parser class TestSequenceFunctions(unittest.TestCase): @@ -150,5 +153,25 @@ def test_str(self): xml_declaration=True ) + def test_updated(self): + date = datetime.datetime(2016, 1, 1, 0, 10, tzinfo=dateutil.tz.tzutc()) + + def getLastBuildDateElement(fg): + return fg._create_rss().find("channel").find("lastBuildDate") + + # Test that it has a default + assert getLastBuildDateElement(self.fg) is not None + + # Test that it respects my custom value + self.fg.updated(date) + lastBuildDate = getLastBuildDateElement(self.fg) + assert lastBuildDate is not None + assert dateutil.parser.parse(lastBuildDate.text) == date + + # Test that it is left out when set to False + self.fg.updated(False) + lastBuildDate = getLastBuildDateElement(self.fg) + assert lastBuildDate is None + if __name__ == '__main__': unittest.main() From 8dd7b55cf958cf800f0d3aef34e5bb97d736e7c9 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 27 Jun 2016 13:05:49 +0200 Subject: [PATCH 048/200] Provide better guidance on valid language codes --- feedgen/feed.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 17cdff6..dbceade 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -530,12 +530,16 @@ def description(self, description=None): 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. - The value should be an IETF language tag. + """Get or set the language of the podcast. - :param language: Language of the feed. + This allows aggregators to group all Italian + language podcasts, for example, on a single page. + + :param language: The language of the podcast. It must be a two-letter + code, as found in ISO639-1, with the + possibility of specifying subcodes (eg. en-US for American English). + See http://www.rssboard.org/rss-language-codes and + http://www.loc.gov/standards/iso639-2/php/code_list.php :returns: Language of the feed. """ if not language is None: From 424d257c00040545e665e8fef5e1785908336bfc Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 27 Jun 2016 13:23:26 +0200 Subject: [PATCH 049/200] Improve documentation of skip* methods --- feedgen/feed.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index dbceade..b7c4838 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -593,15 +593,26 @@ def published(self, pubDate=None): 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. + """Set or get which hours feed readers don't need to refresh this feed. This method can be called with an hour or a list of hours. The hours are - represented as integer values from 0 to 23. + represented as integer values from 0 to 23. When called multiple times, + the new hours are added to the list of existing hours, unless replace + is True. + + For example, to skip hours between 18 and 7:: + + >>> from feedgen.feed import Podcast + >>> p = Podcast() + >>> p.skipHours(range(18, 24)) + {18, 19, 20, 21, 22, 23} + >>> p.skipHours(range(8)) + {0, 1, 2, 3, 4, 5, 6, 7, 18, 19, 20, 21, 22, 23} :param hours: List of hours the feedreaders should not check the feed. + :type hours: list or set or int :param replace: Add or replace old data. - :returns: List of hours the feedreaders should not check the feed. + :returns: Set of hours the feedreaders should not check the feed. """ if not hours is None: if not (isinstance(hours, list) or isinstance(hours, set)): @@ -622,7 +633,8 @@ def skipDays(self, days=None, replace=False): 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 days: List of days the feedreaders should not check the feed. + :type days: list or set or str :param replace: Add or replace old data. :returns: List of days the feedreaders should not check the feed. """ From f86177e5b27c5e0e5fb6cdd03f045b6ecf41b091 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 27 Jun 2016 17:58:12 +0200 Subject: [PATCH 050/200] Start reworking documentation --- Makefile | 4 +- doc/api.feed.rst | 9 ++- doc/api.item.rst | 9 ++- doc/api.rst | 7 ++- doc/api.util.rst | 5 -- doc/conf.py | 37 ++++++++---- doc/index.rst | 108 +++++++++++++++++++++++++++++----- doc/user/index.rst | 13 ++++ doc/user/installing.rst | 22 +++++++ doc/user/introduction.rst | 96 ++++++++++++++++++++++++++++++ feedgen/__init__.py | 121 -------------------------------------- readme.md | 62 ------------------- 12 files changed, 261 insertions(+), 232 deletions(-) create mode 100644 doc/user/index.rst create mode 100644 doc/user/installing.rst create mode 100644 doc/user/introduction.rst diff --git a/Makefile b/Makefile index f5b0054..b19c166 100644 --- a/Makefile +++ b/Makefile @@ -22,9 +22,7 @@ doc-html: @make -C doc html @mkdir -p docs/html @echo 'Copying html to into docs dir' - @cp doc/_build/html/*.html docs/html/ - @cp doc/_build/html/*.js docs/html/ - @cp -r doc/_build/html/_static/ docs/html/ + @cp -T -r doc/_build/html/ docs/html/ doc-man: @echo 'Generating manpage' diff --git a/doc/api.feed.rst b/doc/api.feed.rst index bc165c0..8742c19 100644 --- a/doc/api.feed.rst +++ b/doc/api.feed.rst @@ -1,7 +1,6 @@ -.. raw:: html +==================== +feedgen.feed.Podcast +==================== -
Contents
-
- -.. automodule:: feedgen.feed +.. autoclass:: feedgen.feed.Podcast :members: diff --git a/doc/api.item.rst b/doc/api.item.rst index 2903d12..770ffe0 100644 --- a/doc/api.item.rst +++ b/doc/api.item.rst @@ -1,7 +1,6 @@ -.. raw:: html +======================== +feedgen.item.BaseEpisode +======================== -
Contents
-
- -.. automodule:: feedgen.item +.. autoclass:: feedgen.item.BaseEpisode :members: diff --git a/doc/api.rst b/doc/api.rst index 90610c5..7905e98 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -2,10 +2,11 @@ API Documentation ================= -.. automodule:: feedgen - :members: +.. autosummary:: -Contents: + feedgen.feed.Podcast + feedgen.item.BaseEpisode + feedgen.util .. toctree:: :maxdepth: 2 diff --git a/doc/api.util.rst b/doc/api.util.rst index 47747d5..73fd6fd 100644 --- a/doc/api.util.rst +++ b/doc/api.util.rst @@ -1,7 +1,2 @@ -.. raw:: html - -
Contents
-
- .. automodule:: feedgen.util :members: diff --git a/doc/conf.py b/doc/conf.py index b01ffc4..c417582 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -23,7 +23,9 @@ extensions = [ 'sphinx.ext.intersphinx', 'sphinx.ext.coverage', - 'sphinx.ext.autodoc' + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. @@ -39,8 +41,8 @@ master_doc = 'index' # General information about the project. -project = u'python-feedgen' -copyright = u'2013, Lars Kiesow' +project = u'PodcastGenerator' +copyright = u'2014, Lars Kiesow and 2016, Thorben Dahl' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -90,25 +92,28 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' -html_theme = 'haiku' +html_theme = 'alabaster' -html_style = 'lernfunk.css' +#html_style = 'lernfunk.css' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +html_theme_options = { + 'fixed_sidebar': True, + 'page_width': "1000px", + +} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +html_title = "Podcastgenerator (forked from python-feedgen) Documentation" # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +html_short_title = "Podcastgenerator" # The name of an image file (relative to this directory) to place at the top # of the sidebar. @@ -126,14 +131,22 @@ # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +html_sidebars = { + '**': [ + 'about.html', + 'navigation.html', + 'relations.html', + 'searchbox.html', + 'donate.html', + ] +} # Additional templates that should be rendered to pages, maps page names to # template names. diff --git a/doc/index.rst b/doc/index.rst index 22fc46b..d743f7f 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,25 +1,101 @@ -.. contents:: Table of Contents +================ +PodcastGenerator +================ -.. include-github-readme +Wouldn't it be nice if there was a clean, simple library which could help you +generate podcast RSS feeds from your Python code? Well, today's your lucky day! -.. raw:: html + >>> from feedgen import Podcast + >>> # Create the Podcast + >>> p = Podcast( + name="My Awesome Podcast", + description="My friends and I discuss Python" + " libraries each Tuesday!", + website="http://example.org/awesomepodcast" + ) + >>> # Add some episodes + >>> p.episodes += [ + p.Episode(title="PodcastGenerator rocks!", + media=p.Media("http://example.org/ep1.mp3", 11932295), + summary="I found an awesome library for creating podcasts"), + p.Episode(title="Heard about clint?", + media=p.Media("http://example.org/ep2.mp3", 15363464), + summary="The man behind Requests made something useful " + "for us command-line lovers." + ] + >>> # Generate the RSS feed + >>> rss = str(p) -
+You don't need to read the RSS specification, you don't need to think about +how iTunes interprets things. Just fill in the data, and PodcastGenerator +does the rest for you. -==================== -Module documentation -==================== -.. toctree:: - :maxdepth: 2 - api +----------------- +Generate the Feed +----------------- + +After that you can generate RSS by calling:: + + >>> rssfeed = fg.rss_str() # Get the RSS feed as string + >>> fg.rss_file('rss.xml', minimize=True) # Write the RSS feed to a file + + +---------------- +Add Feed Entries +---------------- + +To add entries (items) to a feed you need to create new BaseEpisode objects and +append them to the list of entries in the Podcast. The most convenient +way to go is to use the Podcast itself for the instantiation of the +BaseEpisode object:: + + >>> fe = fg.add_episode() + >>> fe.id('http://lernfunk.de/media/654321/1') + >>> fe.title('The First Episode') + +The Podcast method `add_episode(...)` without argument provides will +automatically generate a new BaseEpisode object, append it to the feeds internal +list of entries and return it, so that additional data can be added. + +-------------------------- +Using the podcast features +-------------------------- -================== -Indices and tables -================== +All iTunes-specific features are available as methods that start with `itunes_`, +although most features are platform agnostic:: -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + >>> from feedgen.feed import Podcast + >>> fg = Podcast() + ... + >>> fg.itunes_category('Technology', 'Podcasting') + ... + >>> fe = fg.add_episode() + >>> 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() + >>> fg.rss_file('podcast.xml', minimize=True) + + +--------------------- +Testing the Generator +--------------------- + +You can test the module integration-testing-style by simply executing:: + + $ 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 +[`__main__.py`](https://github.com/tobinus/python-feedgen/blob/podcastgen/feedgen/__main__.py). + +.. toctree:: + :hidden: + + user/index + api diff --git a/doc/user/index.rst b/doc/user/index.rst new file mode 100644 index 0000000..5842812 --- /dev/null +++ b/doc/user/index.rst @@ -0,0 +1,13 @@ +========== +User Guide +========== + + +New to PodcastGenerator? This guide will get you up to speed on how this fork +came to be, its license as well as how to install and start using it. + +.. toctree:: + :maxdepth: 2 + + introduction + installing diff --git a/doc/user/installing.rst b/doc/user/installing.rst new file mode 100644 index 0000000..a5a4ca4 --- /dev/null +++ b/doc/user/installing.rst @@ -0,0 +1,22 @@ +============ +Installation +============ + +#. Clone the `GitHub repository`_. + +#. Ensure your project has a virtualenv. + +#. Activate your project's virtualenv. + +#. Install the requirements listed in ``requirements.txt`` inside feedgen:: + + pip install -r requirements.txt + +#. Add this library to the Python path, and you should be able to use it. + + +This is a pretty bad way to install something, but I haven't had the time to +set up a PyPi package yet. Until then, you'd be better off using the original +python-feedgen. + +.. _GitHub repository: https://github.com/tobinus/python-feedgen/tree/podcastgen diff --git a/doc/user/introduction.rst b/doc/user/introduction.rst new file mode 100644 index 0000000..85cc79f --- /dev/null +++ b/doc/user/introduction.rst @@ -0,0 +1,96 @@ +============ +Introduction +============ + + +---------- +Philosophy +---------- + +This project is heavily inspired by the wonderful +[Kenneth Reitz](http://www.kennethreitz.org/projects), known for the +[Requests](http://docs.python-requests.org) library, which features an API which is +as beautiful as it is effective. Watching his +["Documentation is King" talk](http://www.kennethreitz.org/talks/#/documentation-is-king/), +I wanted to make some of the libraries I'm using suitable for use by actual humans. + +This project is to be developed following the same +[PEP 20](https://www.python.org/dev/peps/pep-0020/) idioms as +[Requests](http://docs.python-requests.org/en/master/user/intro/#philosophy): + +1. Beautiful is better than ugly. +2. Explicit is better than implicit. +3. Simple is better than complex. +4. Complex is better than complicated. +5. Readability counts. + +To enable this, the project focuses on one task alone: making it easy to generate a podcast. + +----- +Scope +----- + +This library does NOT help you publish a podcast, or manage podcasts. It's just +a tool that takes information about your podcast, and outputs an RSS feed which +you can then publish however you want. + +Both the process of getting information +about your podcast, and publishing it needs to be done by you. Even then, +it will save you from hammering your head over confusing and undocumented APIs +and conflicting views on how different RSS elements should be used. It also +saves you from reading the RSS specification, the RSS Best Practices and the +documentation for iTunes' Podcast Connect. + +PodcastGenerator is geared towards developers who aren't super familiar with +RSS and XML. If you know exactly how you want the XML to look, then you're +better off using a template engine like Jinja2 (even if friends don't let +friends touch XML bare-handed). + +------------- +Why the fork? +------------- + +This project is a fork of `python-feedgen` which cuts away everything that +doesn't serve the goal of **making it easy and simple to generate podcasts** from +a Python program. **Thus, this project includes only a subset of the features +of `python-feedgen`**. And I don't think anyone in their right mind would accept a pull +request which removes 70% of the features ;-) Among other things, support for ATOM and +Dublin Core is removed, and the remaining code is almost entirely rewritten. + +The reason I felt like making such drastic changes, is that the original library is +**exceptionally hard to learn** and use. Error messages would not tell you what was wrong, +the concept of extensions is poorly explained and the methods are a bit weird, in that +they function as getters and setters at the same time. The fact that you have three +separate ways to go about setting multi-value variables, is also a bit confusing. + +Perhaps the biggest problem, though, is the awkwardness that stems from enabling +RSS and ATOM feeds through the same API. Some methods will map an ATOM value to +its closest sibling in RSS, some in logical ways (like the ATOM method `rights` setting +the value of the RSS property `copyright`) and some differ in subtle ways (like using +(ATOM) `logo` versus (RSS) `image`). Other methods are more complex (see `link`). They're all +confusing, though, since changing one property automatically changes another implicitly. +Removing ATOM (or RSS for that matter) fixes all these issues. + +Even then, the RSS specs and iTunes' podcast recommendations are sometimes able to +cause confusion on their own, especially for those of us who don't have time to +read both specifications from start to end. For example, the original RSS spec +includes support for an image, but that image is required to be less than 144 pixels +wide (88 pixels being the default) and 400 pixels high (remember, this was year _2000_). +Itunes can't have any of that (understandably so), so they added their own `itunes:image` +tag, which has its own set of requirements (images can be no smaller than 1400x1400px!). +I believe **the API should help guide the users** by hiding the legacy image tag, +and gently push users towards the iTunes tag used today. + +In short, the **original project breaks all the idioms listed in Philosophy**, and +fixing it would require changes too big or too dramatic to be applied upstream. +Whenever a change _is_ appropriate for upstream, however, we should strive to +bring it there, so it can benefit **everyone**. + + +------- +License +------- +PodcastGenerator 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/feedgen/__init__.py b/feedgen/__init__.py index 9b60307..4cbd410 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -1,125 +1,4 @@ # -*- coding: utf-8 -*- """ - ============= - Feedgenerator (forked) - ============= - - Ignore: - [![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 podcast feeds in RSS format. - - 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. - - More details about the project: - - - Repository: https://github.com/lkiesow/python-feedgen - - Documentation: http://lkiesow.github.io/python-feedgen/ - - Python Package Index: https://pypi.python.org/pypi/feedgen/ - - - ------------ - Installation - ------------ - - Currently, you'll need to clone this repository, and create a virtualenv and - install lxml and dateutils. - - - ------------- - Create a Feed - ------------- - - To create a feed simply instantiate the Podcast class and insert some - data:: - - >>> from feedgen.feed import Podcast - >>> fg = Podcast() - >>> fg.name('Some Testfeed') - >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) - >>> fg.website( href='http://example.com', rel='alternate' ) - >>> fg.image('http://ex.com/logo.jpg') - >>> fg.description('This is a cool feed!') - >>> fg.website( href='http://larskiesow.de/test.atom') - >>> 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: - - - 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:: - - >>> 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 RSS by calling:: - - >>> rssfeed = fg.rss_str() # Get the RSS feed as string - >>> fg.rss_file('rss.xml', minimize=True) # Write the RSS feed to a file - - - ---------------- - Add Feed Entries - ---------------- - - To add entries (items) to a feed you need to create new BaseEpisode objects and - append them to the list of entries in the Podcast. The most convenient - way to go is to use the Podcast itself for the instantiation of the - BaseEpisode object:: - - >>> fe = fg.add_episode() - >>> fe.id('http://lernfunk.de/media/654321/1') - >>> fe.title('The First Episode') - - The Podcast method `add_episode(...)` without argument provides will - automatically generate a new BaseEpisode object, append it to the feeds internal - list of entries and return it, so that additional data can be added. - - -------------------------- - Using the podcast features - -------------------------- - - All iTunes-specific features are available as methods that start with `itunes_`, - although most features are platform agnostic:: - - >>> from feedgen.feed import Podcast - >>> fg = Podcast() - ... - >>> fg.itunes_category('Technology', 'Podcasting') - ... - >>> fe = fg.add_episode() - >>> 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() - >>> fg.rss_file('podcast.xml', minimize=True) - - - - --------------------- - Testing the Generator - --------------------- - - You can test the module integration-testing-style by simply executing:: - - $ 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 - [`__main__.py`](https://github.com/tobinus/python-feedgen/blob/podcastgen/feedgen/__main__.py). - """ diff --git a/readme.md b/readme.md index 8efaea0..fdaccd5 100644 --- a/readme.md +++ b/readme.md @@ -122,69 +122,7 @@ example for a whole feed generation process, you can find it in the [`__main__.py`](https://github.com/tobinus/python-feedgen/blob/podcastgen/feedgen/__main__.py). ----------- -Philosophy ----------- - -This project is heavily inspired by the wonderful -[Kenneth Reitz](http://www.kennethreitz.org/projects), known for the -[Requests](http://docs.python-requests.org) library, which features an API which is -as beautiful as it is effective. Watching his -["Documentation is King" talk](http://www.kennethreitz.org/talks/#/documentation-is-king/), -I wanted to make some of the libraries I'm using suitable for use by actual humans. - -This project is to be developed following the same -[PEP 20](https://www.python.org/dev/peps/pep-0020/) idioms as -[Requests](http://docs.python-requests.org/en/master/user/intro/#philosophy): - -1. Beautiful is better than ugly. -2. Explicit is better than implicit. -3. Simple is better than complex. -4. Complex is better than complicated. -5. Readability counts. - -To enable this, the project focuses on one task alone: making it easy to generate a podcast. - - -------------- -Why the fork? -------------- -This project is a fork of `python-feedgen` which cuts away everything that -doesn't serve the goal of **making it easy and simple to generate podcasts** from -a Python program. **Thus, this project includes only a subset of the features -of `python-feedgen`**. And I don't think anyone in their right mind would accept a pull -request which removes 70% of the features ;-) Among other things, support for ATOM and -Dublin Core is removed, and the remaining code is almost entirely rewritten. - -The reason I felt like making such drastic changes, is that the original library is -**exceptionally hard to learn** and use. Error messages would not tell you what was wrong, -the concept of extensions is poorly explained and the methods are a bit weird, in that -they function as getters and setters at the same time. The fact that you have three -separate ways to go about setting multi-value variables, is also a bit confusing. - -Perhaps the biggest problem, though, is the awkwardness that stems from enabling -RSS and ATOM feeds through the same API. Some methods will map an ATOM value to -its closest sibling in RSS, some in logical ways (like the ATOM method `rights` setting -the value of the RSS property `copyright`) and some differ in subtle ways (like using -(ATOM) `logo` versus (RSS) `image`). Other methods are more complex (see `link`). They're all -confusing, though, since changing one property automatically changes another implicitly. -Removing ATOM (or RSS for that matter) fixes all these issues. - -Even then, the RSS specs and iTunes' podcast recommendations are sometimes able to -cause confusion on their own, especially for those of us who don't have time to -read both specifications from start to end. For example, the original RSS spec -includes support for an image, but that image is required to be less than 144 pixels -wide (88 pixels being the default) and 400 pixels high (remember, this was year _2000_). -Itunes can't have any of that (understandably so), so they added their own `itunes:image` -tag, which has its own set of requirements (images can be no smaller than 1400x1400px!). -I believe **the API should help guide the users** by hiding the legacy image tag, -and gently push users towards the iTunes tag used today. - -In short, the **original project breaks all the idioms listed in Philosophy**, and -fixing it would require changes too big or too dramatic to be applied upstream. -Whenever a change _is_ appropriate for upstream, however, we should strive to -bring it there, so it can benefit **everyone**. ---------- Known bugs From 0e1a58d539e50823eabff0f561c3323525e14f07 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 28 Jun 2016 02:05:27 +0200 Subject: [PATCH 051/200] Further work on documentation --- Makefile | 1 - doc/_static/custom.css | 6 + doc/api.rst | 38 ++++- doc/conf.py | 18 +- doc/index.rst | 82 ++------- doc/user/example.rst | 14 ++ doc/user/fork.rst | 96 +++++++++++ doc/user/index.rst | 3 + doc/user/introduction.rst | 51 +----- doc/user/use.rst | 351 ++++++++++++++++++++++++++++++++++++++ feedgen/__main__.py | 76 +++++---- feedgen/item.py | 9 +- readme.md | 65 ------- 13 files changed, 589 insertions(+), 221 deletions(-) create mode 100644 doc/_static/custom.css create mode 100644 doc/user/example.rst create mode 100644 doc/user/fork.rst create mode 100644 doc/user/use.rst diff --git a/Makefile b/Makefile index b19c166..fd3c8d8 100644 --- a/Makefile +++ b/Makefile @@ -45,5 +45,4 @@ test: python -m unittest feedgen.tests.test_feed python -m unittest feedgen.tests.test_entry python -m feedgen rss > /dev/null - python -m feedgen podcast > /dev/null @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml diff --git a/doc/_static/custom.css b/doc/_static/custom.css new file mode 100644 index 0000000..d648527 --- /dev/null +++ b/doc/_static/custom.css @@ -0,0 +1,6 @@ +body, div.body { + background-color: #fffded; +} +pre { + background-color: #ebebeb; +} diff --git a/doc/api.rst b/doc/api.rst index 7905e98..93e7601 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -1,15 +1,43 @@ ================= -API Documentation +Developer's Guide ================= -.. autosummary:: - feedgen.feed.Podcast - feedgen.item.BaseEpisode - feedgen.util +------- +Testing +------- + +You can test the module integration-testing-style by simply executing:: + + $ python -m feedgen + +When working on this project, you should run the unit tests as well as the +integration test, like this:: + + $ make test + +The unit tests reside in ``feedgen/tests`` and are written using the +:mod:`unittest` module. + + +----------------- +API Documentation +----------------- + +:class:`feedgen.feed.Podcast` (available as ``feedgen.Podcast``) is the corner +stone of PodcastGenerator. You create one instance of it for each feed you want +to make. + +When you create episodes for a Podcast, you're most likely creating new +instances of :class:`feedgen.item.BaseEpisode`. + +:mod:`feedgen.util` provides utility functions for the rest of the library, +and is therefore not relevant for users. + .. toctree:: :maxdepth: 2 + :hidden: api.feed api.item diff --git a/doc/conf.py b/doc/conf.py index c417582..d23c0f0 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -100,9 +100,15 @@ # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - 'fixed_sidebar': True, + 'fixed_sidebar': False, 'page_width': "1000px", - + 'sidebar_width': "225px", + 'show_related': True, + 'body_text': "rgba(0, 0, 0, 0.8)", + 'footer_text': "rgba(0, 0, 0, 0.5)", + 'gray_1': "rgba(0, 0, 0, 0.9)", + 'gray_2': "rgba(0, 0, 0, 0.2)", + 'gray_3': "rgba(0, 0, 0, 0.1)", } # Add any paths that contain custom themes here, relative to this directory. @@ -139,10 +145,16 @@ # Custom sidebar templates, maps document names to template names. html_sidebars = { - '**': [ + 'index': [ 'about.html', 'navigation.html', + 'searchbox.html', + 'donate.html', + ], + '**': [ + 'about.html', 'relations.html', + 'navigation.html', 'searchbox.html', 'donate.html', ] diff --git a/doc/index.rst b/doc/index.rst index d743f7f..a475d93 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -2,6 +2,12 @@ PodcastGenerator ================ +.. warning:: + + The documentation here represents how things *hopefully* will work once + all the work is done. In the meantime, the :doc:`api` and the :doc:`user/example` + should provide an accurate view of how to use this package. + Wouldn't it be nice if there was a clean, simple library which could help you generate podcast RSS feeds from your Python code? Well, today's your lucky day! @@ -26,76 +32,22 @@ generate podcast RSS feeds from your Python code? Well, today's your lucky day! >>> # Generate the RSS feed >>> rss = str(p) -You don't need to read the RSS specification, you don't need to think about -how iTunes interprets things. Just fill in the data, and PodcastGenerator -does the rest for you. - - - ------------------ -Generate the Feed ------------------ - -After that you can generate RSS by calling:: - - >>> rssfeed = fg.rss_str() # Get the RSS feed as string - >>> fg.rss_file('rss.xml', minimize=True) # Write the RSS feed to a file - - ----------------- -Add Feed Entries ----------------- - -To add entries (items) to a feed you need to create new BaseEpisode objects and -append them to the list of entries in the Podcast. The most convenient -way to go is to use the Podcast itself for the instantiation of the -BaseEpisode object:: - - >>> fe = fg.add_episode() - >>> fe.id('http://lernfunk.de/media/654321/1') - >>> fe.title('The First Episode') - -The Podcast method `add_episode(...)` without argument provides will -automatically generate a new BaseEpisode object, append it to the feeds internal -list of entries and return it, so that additional data can be added. - --------------------------- -Using the podcast features --------------------------- - -All iTunes-specific features are available as methods that start with `itunes_`, -although most features are platform agnostic:: - - >>> from feedgen.feed import Podcast - >>> fg = Podcast() - ... - >>> fg.itunes_category('Technology', 'Podcasting') - ... - >>> fe = fg.add_episode() - >>> 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() - >>> fg.rss_file('podcast.xml', minimize=True) - - - ---------------------- -Testing the Generator ---------------------- +You don't need to read the RSS specification, write XML by hand or wrap your +head around ambigous, undocumented APIs. Just provide the data, and PodcastGenerator +fixes the rest for you! -You can test the module integration-testing-style by simply executing:: +Where to start +-------------- - $ python -m feedgen +Take a look at the :doc:`user/example` for a larger example, read about +:doc:`the project's background ` or refer to +the :doc:`user/use` for a detailed introduction to PodcastGenerator. -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/tobinus/python-feedgen/blob/podcastgen/feedgen/__main__.py). +Contents +-------- .. toctree:: - :hidden: + :maxdepth: 3 user/index api diff --git a/doc/user/example.rst b/doc/user/example.rst new file mode 100644 index 0000000..37bba43 --- /dev/null +++ b/doc/user/example.rst @@ -0,0 +1,14 @@ +=============== +Working example +=============== + +Below is a working example of how you can go about using PodcastGenerator. It +also shows you how you can use the different properties of Podcast and Episode. + +.. literalinclude:: ../../feedgen/__main__.py + :pyobject: main + :linenos: + +Once you understand the basic way you do things, you're ready to look at the +:doc:`/api` in conjunction with the :doc:`use` to see exactly what properties you can set, and how they +affect the end result. diff --git a/doc/user/fork.rst b/doc/user/fork.rst new file mode 100644 index 0000000..2527b2f --- /dev/null +++ b/doc/user/fork.rst @@ -0,0 +1,96 @@ +============= +Why the fork? +============= + +This project is a fork of ``python-feedgen`` which cuts away everything that +doesn't serve the goal of **making it easy and simple to generate podcasts** from +a Python program. Thus, this project includes only a **subset** of the features +of ``python-feedgen``. And I don't think anyone in their right mind would accept a pull +request which removes 70% of the features ;-) Among other things, support for ATOM and +Dublin Core is removed, and the remaining code is almost entirely rewritten. + + +Inspiration +----------- + +The reason I felt like making such drastic changes, is that the original library is +**exceptionally hard to learn** and use. Error messages would not tell you what was wrong, +the concept of extensions is poorly explained and the methods are a bit weird, in that +they function as getters and setters at the same time. The fact that you have three +separate ways to go about setting multi-value variables, is also a bit confusing. + +Perhaps the biggest problem, though, is the awkwardness that stems from enabling +RSS and ATOM feeds through the same API. In case you don't know, ATOM is a +"competitor" to RSS, and has many more capabilities than RSS. However, it is +not used for podcasting. If you're curiousSome methods will map an ATOM value to +its closest sibling in RSS, some in logical ways (like the ATOM method ``rights`` setting +the value of the RSS property ``copyright``) and some differ in subtle ways (like using +(ATOM) ``logo`` versus (RSS) ``image``). Other methods are more complex (see ``link``). They're all +confusing, though, since changing one property automatically changes another implicitly. +They also cause bugs, since it is so difficult to map your head around how one +interact with another. +Removing ATOM fixes all these issues. + +Even then, ``python-feedgen`` aims at being comprehensive, which means you must +learn the RSS and podcast standards, which include many legacy elements you +don't really need. For example, the original RSS spec +includes support for an image, but that image is required to be less than 144 pixels +wide (88 pixels being the default) and 400 pixels high (remember, this was year *2000*). +Itunes can't have any of that (understandably so), so they added their own ``itunes:image`` +tag, which has its own set of requirements (images can be no smaller than 1400x1400px!). +I believe **the API should help guide the users** by hiding the legacy image tag, +and you as a user shouldn't need to know all this. You just need to know that the +image must be larger than 1400x1400 pixels, not the history behind everything. + +Alignment with the philosophies +------------------------------- + +``python-feedgen``'s code breaks all the philosophies listed above: + +#. Beautiful is better than ugly, yet all properties are set through hybrid + setter/getter methods. +#. Explicit is better than implicit, yet changing one property will cause + changes to other properties implicitly. +#. Simple is better than complex, yet creating podcasts requires that you + load an extension, and somehow figure out that this extension's methods + are available as methods of the extension's name, which suddenly is + available as a property of your FeedGenerator object. +#. Complex is better than complicated, yet an entire framework is built to + handle extensions, rather than using class inheritance. +#. Readability counts, yet classes are named after their function and not what + they represent, and (again) properties are set through methods. + +In short, the **original project breaks all the idioms listed in Philosophy**, and +fixing it would require changes too big or too dramatic to be applied upstream. + +Whenever a change *is* appropriate for upstream, however, we should strive to +bring it there, so it can benefit **everyone**. + + +Summary of changes +------------------ + +* ``FeedGenerator`` is renamed to :class:`~feedgen.feed.Podcast` and ``FeedItem`` is accessed + at ``Podcast.Episode`` (or directly: :class:`~feedgen.item.BaseEpisode`). +* Support for ATOM removed. +* Move from using getter and setter methods to using properties, which you can + assign just like you would assign any other property. + + * Compound values (like managingEditor or enclosure) use + classes now. + +* Remove support for some uncommon elements: + + * ttl + * category + * image + * itunes:summary + * rating + * textInput + +* Rename the remaining properties so their names don't necessarily match the RSS + elements they map to. Instead, the names should be descriptive and easy to + understand. +* Add shorthand for generating the RSS: Just try to converting your :class:`~feedgen.feed.Podcast` + object to :obj:`str`! +* Improve the documentation diff --git a/doc/user/index.rst b/doc/user/index.rst index 5842812..eb17e48 100644 --- a/doc/user/index.rst +++ b/doc/user/index.rst @@ -10,4 +10,7 @@ came to be, its license as well as how to install and start using it. :maxdepth: 2 introduction + fork installing + use + example diff --git a/doc/user/introduction.rst b/doc/user/introduction.rst index 85cc79f..a3abf49 100644 --- a/doc/user/introduction.rst +++ b/doc/user/introduction.rst @@ -8,15 +8,15 @@ Philosophy ---------- This project is heavily inspired by the wonderful -[Kenneth Reitz](http://www.kennethreitz.org/projects), known for the -[Requests](http://docs.python-requests.org) library, which features an API which is +`Kenneth Reitz `_, known for the +`Requests `_ library, which features an API which is as beautiful as it is effective. Watching his -["Documentation is King" talk](http://www.kennethreitz.org/talks/#/documentation-is-king/), +`"Documentation is King" talk `_, I wanted to make some of the libraries I'm using suitable for use by actual humans. This project is to be developed following the same -[PEP 20](https://www.python.org/dev/peps/pep-0020/) idioms as -[Requests](http://docs.python-requests.org/en/master/user/intro/#philosophy): +`PEP 20 `_ idioms as +`Requests `_: 1. Beautiful is better than ugly. 2. Explicit is better than implicit. @@ -46,47 +46,6 @@ RSS and XML. If you know exactly how you want the XML to look, then you're better off using a template engine like Jinja2 (even if friends don't let friends touch XML bare-handed). -------------- -Why the fork? -------------- - -This project is a fork of `python-feedgen` which cuts away everything that -doesn't serve the goal of **making it easy and simple to generate podcasts** from -a Python program. **Thus, this project includes only a subset of the features -of `python-feedgen`**. And I don't think anyone in their right mind would accept a pull -request which removes 70% of the features ;-) Among other things, support for ATOM and -Dublin Core is removed, and the remaining code is almost entirely rewritten. - -The reason I felt like making such drastic changes, is that the original library is -**exceptionally hard to learn** and use. Error messages would not tell you what was wrong, -the concept of extensions is poorly explained and the methods are a bit weird, in that -they function as getters and setters at the same time. The fact that you have three -separate ways to go about setting multi-value variables, is also a bit confusing. - -Perhaps the biggest problem, though, is the awkwardness that stems from enabling -RSS and ATOM feeds through the same API. Some methods will map an ATOM value to -its closest sibling in RSS, some in logical ways (like the ATOM method `rights` setting -the value of the RSS property `copyright`) and some differ in subtle ways (like using -(ATOM) `logo` versus (RSS) `image`). Other methods are more complex (see `link`). They're all -confusing, though, since changing one property automatically changes another implicitly. -Removing ATOM (or RSS for that matter) fixes all these issues. - -Even then, the RSS specs and iTunes' podcast recommendations are sometimes able to -cause confusion on their own, especially for those of us who don't have time to -read both specifications from start to end. For example, the original RSS spec -includes support for an image, but that image is required to be less than 144 pixels -wide (88 pixels being the default) and 400 pixels high (remember, this was year _2000_). -Itunes can't have any of that (understandably so), so they added their own `itunes:image` -tag, which has its own set of requirements (images can be no smaller than 1400x1400px!). -I believe **the API should help guide the users** by hiding the legacy image tag, -and gently push users towards the iTunes tag used today. - -In short, the **original project breaks all the idioms listed in Philosophy**, and -fixing it would require changes too big or too dramatic to be applied upstream. -Whenever a change _is_ appropriate for upstream, however, we should strive to -bring it there, so it can benefit **everyone**. - - ------- License ------- diff --git a/doc/user/use.rst b/doc/user/use.rst new file mode 100644 index 0000000..72d62e0 --- /dev/null +++ b/doc/user/use.rst @@ -0,0 +1,351 @@ +Basic usage guide +================= + +When using PodcastGenerator, you can divide your program into +three phases: + +#. Populating the podcast +#. Adding episodes +#. Generating the RSS + +While the +:doc:`example` gives you a practical introduction, this document helps you +understand what the different attributes mean and how they should be used. +It complements the :doc:`../api` nicely. + +Populating the podcast +---------------------- + +Creating a new instance +~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + from feedgen import Podcast + p = Podcast() + +Mandatory properties +~~~~~~~~~~~~~~~~~~~~ + +Next, we will give the podcast some metadata:: + + p.name = "My Example Podcast" + p.description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + p.website = "https://example.org" + +Those three properties, :attr:`~feedgen.feed.Podcast.name`, +:attr:`~feedgen.feed.Podcast.description` and +:attr:`~feedgen.feed.Podcast.website`, are actually +the only three **mandatory** properties of +:class:`~feedgen.feed.Podcast`. A summary of them: + +.. autosummary:: + + ~feedgen.feed.Podcast.name + ~feedgen.feed.Podcast.description + ~feedgen.feed.Podcast.website + +Image +~~~~~ + +A podcast's image is worth special attention:: + + p.image = "https://example.com/static/example_podcast.png" + +.. automethod:: feedgen.feed.Podcast.itunes_image + :noindex: + +Even though the image *technically* is optional, you won't reach people without. + +Optional properties +~~~~~~~~~~~~~~~~~~~ + +There are plenty of other properties that can be used with +:class:`feedgen.feed.Podcast `: + + +Commonly used +^^^^^^^^^^^^^ + +:: + + p.copyright = "© 2016 Example Radio" + p.language = "en-US" + p.managingEditor = p.Person("John Doe", "editor@example.org") + p.feed_url = "https://example.com/feeds/podcast.rss" + p.category = Category("Technology", "Podcasting") + p.explicit = True + p.owner = p.managingEditor + +.. autosummary:: + + ~feedgen.feed.Podcast.copyright + ~feedgen.feed.Podcast.language + ~feedgen.feed.Podcast.managingEditor + ~feedgen.feed.Podcast.feed_url + ~feedgen.feed.Podcast.category + ~feedgen.feed.Podcast.explicit + ~feedgen.feed.Podcast.owner + + +Less commonly used +^^^^^^^^^^^^^^^^^^ + +Some of those are obscure while some of them are often times not needed. Others +again have very reasonable defaults. Remember to click on a name to read its +full description. + +:: + + p.cloud = p.CloudService("server.example.com", "/rpc", 80, "xml-rpc") + + import datetime + import pytz + p.updated = datetime.datetime(2016, 5, 18, 0, 0, tzinfo=pytz.utc)) + p.published = datetime.datetime(2016, 5, 17, 15, 32, tzinfo=pytz.utc)) + + p.skipDays = {"Friday", "Saturday", "Sunday"} + p.skipHours = set(range(8)) + p.skipHours |= set(range(16, 24)) + p.webMaster = p.Person(None, "helpdesk@dallas.example.com") + # Be very careful about using the following attributes: + p.new_feed_url = "https://podcast.example.com/example" + p.complete = True + p.withhold_from_itunes = True + +.. autosummary:: + + ~feedgen.feed.Podcast.cloud + ~feedgen.feed.Podcast.updated + ~feedgen.feed.Podcast.published + ~feedgen.feed.Podcast.skipDays + ~feedgen.feed.Podcast.skipHours + ~feedgen.feed.Podcast.webMaster + ~feedgen.feed.Podcast.new_feed_url + ~feedgen.feed.Podcast.complete + ~feedgen.feed.Podcast.withhold_from_itunes + + + +Adding episodes +--------------- + +To add episodes to a feed, you need to create new +:attr:`feedgen.Podcast` objects and +append them to the list of entries in the Podcast. That is pretty +straight-forward:: + + my_episode = p.Episode() + p.episodes.append(my_episode) + +There is a conveinence method called :meth:`Podcast.add_episode ` +which optionally creates a new instance of ``Episode``, adds it to the podcast +and returns it, allowing you to assign it to a variable:: + + my_episode = p.add_episode() + +If you prefer to use the constructor, there's nothing wrong with that:: + + my_episode = p.add_episode(p.Episode()) + +The advantage of using the latter form, is that you can pass data to the +constructor, which can make your code more compact and readable. + +Filling with data +~~~~~~~~~~~~~~~~~ + +There is only one rule for episodes: **they must have either a title or a +summary**, or both. Additionally, you can opt to have a long summary, as +well as a short subtitle:: + + my_episode.title = "S01E10: The Best Example of them All" + my_episode.subtitle = "We found the greatest example!" + my_episode.summary = "In this week's episode, we have found the " + \ + "greatest example of them all." + my_episode.long_summary = "In this week's episode, we went out on a " + \ + "search to find the greatest example of them " + \ + "all.
Today's intro music: " + \ + "Example Song" + +They're all pretty obvious: + +.. autosummary:: + + ~feedgen.item.BaseEpisode.title + ~feedgen.item.BaseEpisode.subtitle + ~feedgen.item.BaseEpisode.summary + ~feedgen.item.BaseEpisode.long_summary + +Enclosing media +^^^^^^^^^^^^^^^ + +Of course, this isn't much of a podcast if we don't have any **media** +attached to it! :: + + my_episode.media = p.Media("http://example.com/podcast/s01e10.mp3", + size=p.Media.Auto, + duration="1:02:36") + +Normally, you must specify how big the **file size** is in bytes, but PodcastGenerator +can send a HEAD request to the URL and retrieve how many bytes it is +automatically by using p.Media.Auto as shown. This only works if `requests `_ +is installed, though! If you know how big it is, you're better off not using +this feature, like this:: + + my_episode.media = p.Media("http://example.com/podcast/s01e10.mp3", + size=17475653, + duration="1:02:36") + +The **type** of the media file is derived from the URI ending. Even though you +technically can have file names which don't end in their actual file extension, +iTunes will use the file extension to determine what type of file it is, without +even asking the server. You must therefore make sure your media files have the +correct file extension. + +The **duration** is also important to include, for your listeners' convenience. +Without it, they won't know how long an episode is before they start downloading +and listening. + +.. autosummary:: + + ~feedgen.item.BaseEpisode.media + ~feedgen.feed.Podcast.Media + + +Identifying the episode +^^^^^^^^^^^^^^^^^^^^^^^ + +Every episode is identified by a **globally unique identifier (GUID)**. +By default, this id is set to be the same as the URL of the media (see above) +when the feed is generated. +That is, given the example above, the id of ``my_episode`` would be +``http://example.com/podcast/s01e10.mp3``. + +This has the implication that **if you don't set id, the media URL must stay +the same**. + +.. autosummary:: ~feedgen.item.BaseEpisode.id + + +Episode's publication date +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +An episode's publication date indicates when the episode first went live. It is +used to indicate how old the episode is, and a client may say an episode is from +"1 hour ago", "yesterday", "last week" and so on. You should therefore make sure +that it matches the exact time that the episode went live, or else your listeners +will get a new episode which appears to have existed for longer than it has. +This is why it's generally a bad idea to use the media file's modification date +as the publication date when you make your episodes some time in advance +– your listeners will suddenly get an "old" episode in +their feed! :: + + my_episode.published_date = datetime.datetime(2016, 5, 18, 10, 0, + tzinfo=pytz.utc) + +.. autosummary:: ~feedgen.item.BaseEpisode.published_date + + +The Link +^^^^^^^^ + +If you're publishing articles along with your podcast episodes, you should +link to the relevant article. Examples can be linking to the sound on +SoundCloud or the post on your website. Usually, your +listeners expect to find the entirety of the :attr:`~feedgen.item.BaseEpisode.summary` by following +the link. :: + + my_episode.link = "http://example.com/article/2016/05/18/Best-example" + +If you don't have anything to link to, then that's fine as well. No link is +better than a disappointing link. + +.. autosummary:: ~feedgen.item.BaseEpisode.link + + +The Author +^^^^^^^^^^ + +There is no point in copy+pasting the same author name in every single episode. +Instead, you should just set :attr:`Podcast.managingEditor ` +to the appropriate name and/or email address, and don't set any authors on the +episodes. iTunes and others are smart enough to understand that the person +or entity named in :attr:`Podcast.managingEditor ` +is responsible for all episodes. + +If the author of an episode differs from the rest, though, you can use +:attr:`the author attribute ` to indicate that:: + + my_episode.author = Person("Joe Bob") + +You can even have multiple authors:: + + my_episode.author = [Person("Joe Bob"), Person("Alice Bob")] + +.. autosummary:: ~feedgen.item.BaseEpisode.author + + +Category +^^^^^^^^ + +An episode can have a different category than the rest of the podcast:: + + my_episode.category = Category("Arts", "Food") + +.. autosummary:: ~feedgen.item.BaseEpisode.category + + +Less used attributes +^^^^^^^^^^^^^^^^^^^^ + +:: + + my_episode.image = "http://example.com/static/best-example.png" + my_episode.explicit = False + my_episode.is_close_captioned = False # Only applicable for video + my_episode.order = 1 + # Be careful about using the following attribute! + my_episode.withhold_from_itunes = True + +.. autosummary:: + + ~feedgen.item.BaseEpisode.image + ~feedgen.item.BaseEpisode.explicit + ~feedgen.item.BaseEpisode.is_close_captioned + ~feedgen.item.BaseEpisode.order + ~feedgen.item.BaseEpisode.withhold_from_itunes + +Generating the RSS +------------------ + +Once you've added all the information and all episodes, it's time to +take the final step:: + + rssfeed = p.rss_str() + # Print to stdout, just as an example + print(rssfeed) + +If you're okay with the default parameters of :meth:`feedgen.feed.Podcast.rss_str`, +you can use a shortcut by converting :class:`~feedgen.feed.Podcast` to :obj:`str`:: + + rssfeed = str(p) + # Or let print convert to str for you + print(p) + +Doing so is the same as calling :meth:`feedgen.feed.Podcast.rss_str` with no +parameters. + +.. autosummary:: + + ~feedgen.feed.Podcast.rss_str + +You may also write the feed to a file directly, using :meth:`feedgen.feed.Podcast.rss_file`:: + + fg.rss_file('rss.xml', minimize=True) + + +.. autosummary:: + + ~feedgen.feed.Podcast.rss_file + + diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 7355cc0..a410089 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -8,11 +8,11 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from feedgen.feed import Podcast import sys import datetime import pytz + def print_enc(s): '''Print function compatible with both python2 and python3 accepting strings and byte arrays. @@ -23,53 +23,63 @@ def print_enc(s): print(s) - -if __name__ == '__main__': +def main(): + """Create an example podcast and, print it or save it to file.""" + # There must be exactly one argument, and it is must end with rss if len(sys.argv) != 2 or not ( - sys.argv[1].endswith('rss') \ - or sys.argv[1].endswith('podcast') ): - print_enc ('Usage: %s ( .rss | rss | podcast )' % \ + sys.argv[1].endswith('rss')): + # Invalid usage, print help message + # print_enc is just a custom function which functions like print, + # except it deals with byte arrays properly. + print_enc ('Usage: %s ( .rss | rss )' % \ 'python -m feedgen') print_enc ('') print_enc (' rss -- Generate RSS test output and print it to stdout.') 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 ('') exit() + # Remember what type of feed the user wants arg = sys.argv[1] - fg = Podcast() - fg.name('Testfeed') - fg.managingEditor('lkiesow@uos.de (Lars Kiesow)') - fg.website(href='http://example.com') - fg.copyright('cc-by') - fg.description('This is a cool feed!') - fg.language('de') - fg.feed_url('http://example.com/feeds/myfeed.rss') - fe = fg.add_episode() - fe.id('http://lernfunk.de/_MEDIAID_123#1') - fe.title('First Element') - fe.summary('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen + from feedgen.feed import Podcast + # Initialize the feed + p = Podcast() + p.name('Testfeed') + p.managingEditor('lkiesow@uos.de (Lars Kiesow)') + p.website(href='http://example.com') + p.copyright('cc-by') + p.description('This is a cool feed!') + p.language('de') + p.feed_url('http://example.com/feeds/myfeed.rss') + p.itunes_author('Lars Kiesow') + p.itunes_category('Technology', 'Podcasting') + p.itunes_explicit('no') + p.itunes_complete('no') + p.itunes_new_feed_url('http://example.com/new-feed.rss') + p.itunes_owner('John Doe', 'john@example.com') + + e1 = p.add_episode() + e1.id('http://lernfunk.de/_MEDIAID_123#1') + e1.title('First Element') + e1.summary('''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 <3.''', html=False) - fe.link( href='http://example.com') - fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) + e1.link(href='http://example.com') + e1.author(name='Lars Kiesow', email='lkiesow@uos.de') + e1.itunes_author('Lars Kiesow') + e1.published(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) + # Should we just print out, or write to file? if arg == 'rss': - print_enc(fg.rss_str()) - elif arg == 'podcast': - fg.itunes_author('Lars Kiesow') - fg.itunes_category('Technology', 'Podcasting') - fg.itunes_explicit('no') - fg.itunes_complete('no') - fg.itunes_new_feed_url('http://example.com/new-feed.rss') - fg.itunes_owner('John Doe', 'john@example.com') - fe.itunes_author('Lars Kiesow') - fe.published(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) - print_enc(fg.rss_str()) + # Print + print_enc(p.rss_str()) elif arg.endswith('rss'): - fg.rss_file(arg, minimize=True) + # Write to file + p.rss_file(arg, minimize=True) + +if __name__ == '__main__': + main() diff --git a/feedgen/item.py b/feedgen/item.py index 8253306..7f43bcc 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -149,8 +149,11 @@ def title(self, title=None): def id(self, new_id=None): """Get or set this episode's globally unique identifier. - If not present, the URL of the enclosed media is used. Set the id to - boolean False to suppress this behaviour. + If not present, the URL of the enclosed media is used. This is usually + the best way to go, **as long as the media URL doesn't change**. + + Set the id to boolean False if you don't want to associate any id to + this episode. It is important that an episode keeps the same ID until the end of time, since the ID is used by clients to identify which episodes have been @@ -162,7 +165,7 @@ def id(self, new_id=None): be unique in this podcast, it must not be the same ID as any other episode for any podcast out there. To ensure this, you should use a domain which you own (for example, use something like - http://example.org/podcast/episode1.mp3 if you own example.org). + http://example.org/podcast/episode1 if you own example.org). This property corresponds to the RSS GUID element. diff --git a/readme.md b/readme.md index fdaccd5..5cd4e60 100644 --- a/readme.md +++ b/readme.md @@ -59,71 +59,6 @@ Example:: >>> 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 RSS by calling: - - >>> rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string - >>> fg.rss_file('rss.xml') # Write the RSS feed to a file - - ----------------- -Add Feed Entries ----------------- - -To add entries (items) to a feed you need to create new BaseEpisode objects and -append them to the list of entries in the Podcast. The most convenient -way to go is to use the Podcast itself for the instantiation of the -BaseEpisode object: - - >>> fe = fg.add_episode() - >>> fe.id('http://lernfunk.de/media/654321/1') - >>> fe.title('The First BaseEpisode') - -The FeedGenerators method `add_episode(...)` without argument provides will -automatically generate a new BaseEpisode object, append it to the feeds internal -list of entries and return it, so that additional data can be added. - --------------------------- -Using the podcast features --------------------------- - -Most iTunes-specific features are available as methods that start with `itunes_`, -although most features are platform agnostic. - - >>> from feedgen.feed import Podcast - >>> fg = Podcast() - ... - >>> fg.itunes_category('Technology', 'Podcasting') - ... - >>> fe = fg.add_episode() - >>> 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') - - - ---------------------- -Testing the Generator ---------------------- - -You can test the module integration-testing-style by simply executing:: - - $ 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 -[`__main__.py`](https://github.com/tobinus/python-feedgen/blob/podcastgen/feedgen/__main__.py). - - - - ---------- Known bugs ---------- From ca46ae0d182c38a36e60a43be708660ffacfbc0b Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 29 Jun 2016 10:50:14 +0200 Subject: [PATCH 052/200] Reorganize the doc a bit, improve formatting, small fixes to doc --- doc/index.rst | 2 +- doc/user/basic_usage_guide/index.rst | 18 ++ doc/user/basic_usage_guide/part_1.rst | 114 ++++++++++ .../{use.rst => basic_usage_guide/part_2.rst} | 212 +++--------------- doc/user/basic_usage_guide/part_3.rst | 36 +++ doc/user/example.rst | 2 +- doc/user/index.rst | 4 +- doc/user/{installing.rst => installation.rst} | 0 doc/user/introduction.rst | 13 +- feedgen/item.py | 2 +- 10 files changed, 213 insertions(+), 190 deletions(-) create mode 100644 doc/user/basic_usage_guide/index.rst create mode 100644 doc/user/basic_usage_guide/part_1.rst rename doc/user/{use.rst => basic_usage_guide/part_2.rst} (52%) create mode 100644 doc/user/basic_usage_guide/part_3.rst rename doc/user/{installing.rst => installation.rst} (100%) diff --git a/doc/index.rst b/doc/index.rst index a475d93..85718b7 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -41,7 +41,7 @@ Where to start Take a look at the :doc:`user/example` for a larger example, read about :doc:`the project's background ` or refer to -the :doc:`user/use` for a detailed introduction to PodcastGenerator. +the :doc:`user/basic_usage_guide/index` for a detailed introduction to PodcastGenerator. Contents -------- diff --git a/doc/user/basic_usage_guide/index.rst b/doc/user/basic_usage_guide/index.rst new file mode 100644 index 0000000..03471fd --- /dev/null +++ b/doc/user/basic_usage_guide/index.rst @@ -0,0 +1,18 @@ +Basic usage guide +================= + +When using PodcastGenerator, you can divide your program into +three phases: + +.. toctree:: + :maxdepth: 1 + + part_1 + part_2 + part_3 + +While the +:doc:`../example` gives you a practical introduction, this document helps you +understand what the different attributes mean and how they should be used. +It complements the :doc:`/api` nicely. + diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst new file mode 100644 index 0000000..38d1507 --- /dev/null +++ b/doc/user/basic_usage_guide/part_1.rst @@ -0,0 +1,114 @@ +Populating the podcast +---------------------- + +Creating a new instance +~~~~~~~~~~~~~~~~~~~~~~~ + +:: + + from feedgen import Podcast + p = Podcast() + +Mandatory properties +~~~~~~~~~~~~~~~~~~~~ + +:: + + p.name = "My Example Podcast" + p.description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + p.website = "https://example.org" + +Those three properties, :attr:`~feedgen.feed.Podcast.name`, +:attr:`~feedgen.feed.Podcast.description` and +:attr:`~feedgen.feed.Podcast.website`, are actually +the only three **mandatory** properties of +:class:`~feedgen.feed.Podcast`. A summary of them: + +.. autosummary:: + + ~feedgen.feed.Podcast.name + ~feedgen.feed.Podcast.description + ~feedgen.feed.Podcast.website + +Image +~~~~~ + +A podcast's image is worth special attention:: + + p.image = "https://example.com/static/example_podcast.png" + +.. automethod:: feedgen.feed.Podcast.itunes_image + :noindex: + +Even though the image *technically* is optional, you won't reach people without it. + +Optional properties +~~~~~~~~~~~~~~~~~~~ + +There are plenty of other properties that can be used with +:class:`feedgen.feed.Podcast `: + + +Commonly used +^^^^^^^^^^^^^ + +:: + + p.copyright = "© 2016 Example Radio" + p.language = "en-US" + p.managingEditor = p.Person("John Doe", "editor@example.org") + p.feed_url = "https://example.com/feeds/podcast.rss" + p.category = Category("Technology", "Podcasting") + p.explicit = True + p.owner = p.managingEditor + +.. autosummary:: + + ~feedgen.feed.Podcast.copyright + ~feedgen.feed.Podcast.language + ~feedgen.feed.Podcast.managingEditor + ~feedgen.feed.Podcast.feed_url + ~feedgen.feed.Podcast.category + ~feedgen.feed.Podcast.explicit + ~feedgen.feed.Podcast.owner + + +Less commonly used +^^^^^^^^^^^^^^^^^^ + +Some of those are obscure while some of them are often times not needed. Others +again have very reasonable defaults. Remember to click on a name to read its +full description. + +:: + + p.cloud = p.CloudService("server.example.com", "/rpc", 80, "xml-rpc") + + import datetime + import pytz + p.updated = datetime.datetime(2016, 5, 18, 0, 0, tzinfo=pytz.utc)) + p.published = datetime.datetime(2016, 5, 17, 15, 32, tzinfo=pytz.utc)) + + p.skipDays = {"Friday", "Saturday", "Sunday"} + p.skipHours = set(range(8)) + p.skipHours |= set(range(16, 24)) + p.webMaster = p.Person(None, "helpdesk@dallas.example.com") + # Be very careful about using the following attributes: + p.new_feed_url = "https://podcast.example.com/example" + p.complete = True + p.withhold_from_itunes = True + +.. autosummary:: + + ~feedgen.feed.Podcast.cloud + ~feedgen.feed.Podcast.updated + ~feedgen.feed.Podcast.published + ~feedgen.feed.Podcast.skipDays + ~feedgen.feed.Podcast.skipHours + ~feedgen.feed.Podcast.webMaster + ~feedgen.feed.Podcast.new_feed_url + ~feedgen.feed.Podcast.complete + ~feedgen.feed.Podcast.withhold_from_itunes + + +Next step is :doc:`part_2`. diff --git a/doc/user/use.rst b/doc/user/basic_usage_guide/part_2.rst similarity index 52% rename from doc/user/use.rst rename to doc/user/basic_usage_guide/part_2.rst index 72d62e0..c55e36e 100644 --- a/doc/user/use.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -1,137 +1,9 @@ -Basic usage guide -================= - -When using PodcastGenerator, you can divide your program into -three phases: - -#. Populating the podcast -#. Adding episodes -#. Generating the RSS - -While the -:doc:`example` gives you a practical introduction, this document helps you -understand what the different attributes mean and how they should be used. -It complements the :doc:`../api` nicely. - -Populating the podcast ----------------------- - -Creating a new instance -~~~~~~~~~~~~~~~~~~~~~~~ - -:: - - from feedgen import Podcast - p = Podcast() - -Mandatory properties -~~~~~~~~~~~~~~~~~~~~ - -Next, we will give the podcast some metadata:: - - p.name = "My Example Podcast" - p.description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." - p.website = "https://example.org" - -Those three properties, :attr:`~feedgen.feed.Podcast.name`, -:attr:`~feedgen.feed.Podcast.description` and -:attr:`~feedgen.feed.Podcast.website`, are actually -the only three **mandatory** properties of -:class:`~feedgen.feed.Podcast`. A summary of them: - -.. autosummary:: - - ~feedgen.feed.Podcast.name - ~feedgen.feed.Podcast.description - ~feedgen.feed.Podcast.website - -Image -~~~~~ - -A podcast's image is worth special attention:: - - p.image = "https://example.com/static/example_podcast.png" - -.. automethod:: feedgen.feed.Podcast.itunes_image - :noindex: - -Even though the image *technically* is optional, you won't reach people without. - -Optional properties -~~~~~~~~~~~~~~~~~~~ - -There are plenty of other properties that can be used with -:class:`feedgen.feed.Podcast `: - - -Commonly used -^^^^^^^^^^^^^ - -:: - - p.copyright = "© 2016 Example Radio" - p.language = "en-US" - p.managingEditor = p.Person("John Doe", "editor@example.org") - p.feed_url = "https://example.com/feeds/podcast.rss" - p.category = Category("Technology", "Podcasting") - p.explicit = True - p.owner = p.managingEditor - -.. autosummary:: - - ~feedgen.feed.Podcast.copyright - ~feedgen.feed.Podcast.language - ~feedgen.feed.Podcast.managingEditor - ~feedgen.feed.Podcast.feed_url - ~feedgen.feed.Podcast.category - ~feedgen.feed.Podcast.explicit - ~feedgen.feed.Podcast.owner - - -Less commonly used -^^^^^^^^^^^^^^^^^^ - -Some of those are obscure while some of them are often times not needed. Others -again have very reasonable defaults. Remember to click on a name to read its -full description. - -:: - - p.cloud = p.CloudService("server.example.com", "/rpc", 80, "xml-rpc") - - import datetime - import pytz - p.updated = datetime.datetime(2016, 5, 18, 0, 0, tzinfo=pytz.utc)) - p.published = datetime.datetime(2016, 5, 17, 15, 32, tzinfo=pytz.utc)) - - p.skipDays = {"Friday", "Saturday", "Sunday"} - p.skipHours = set(range(8)) - p.skipHours |= set(range(16, 24)) - p.webMaster = p.Person(None, "helpdesk@dallas.example.com") - # Be very careful about using the following attributes: - p.new_feed_url = "https://podcast.example.com/example" - p.complete = True - p.withhold_from_itunes = True - -.. autosummary:: - - ~feedgen.feed.Podcast.cloud - ~feedgen.feed.Podcast.updated - ~feedgen.feed.Podcast.published - ~feedgen.feed.Podcast.skipDays - ~feedgen.feed.Podcast.skipHours - ~feedgen.feed.Podcast.webMaster - ~feedgen.feed.Podcast.new_feed_url - ~feedgen.feed.Podcast.complete - ~feedgen.feed.Podcast.withhold_from_itunes - - Adding episodes --------------- To add episodes to a feed, you need to create new -:attr:`feedgen.Podcast` objects and +:attr:`Podcast.Episode ` objects and append them to the list of entries in the Podcast. That is pretty straight-forward:: @@ -176,6 +48,7 @@ They're all pretty obvious: ~feedgen.item.BaseEpisode.summary ~feedgen.item.BaseEpisode.long_summary + Enclosing media ^^^^^^^^^^^^^^^ @@ -221,8 +94,10 @@ when the feed is generated. That is, given the example above, the id of ``my_episode`` would be ``http://example.com/podcast/s01e10.mp3``. -This has the implication that **if you don't set id, the media URL must stay -the same**. +.. warning:: + + An episode's ID should never change. Therefore, **if you don't set id, the + media URL must never change either**. .. autosummary:: ~feedgen.item.BaseEpisode.id @@ -235,10 +110,15 @@ used to indicate how old the episode is, and a client may say an episode is from "1 hour ago", "yesterday", "last week" and so on. You should therefore make sure that it matches the exact time that the episode went live, or else your listeners will get a new episode which appears to have existed for longer than it has. -This is why it's generally a bad idea to use the media file's modification date -as the publication date when you make your episodes some time in advance -– your listeners will suddenly get an "old" episode in -their feed! :: + +.. note:: + + It is generally a bad idea to use the media file's modification date + as the publication date when you make your episodes some time in advance + – your listeners will suddenly get an "old" episode in + their feed! + +:: my_episode.published_date = datetime.datetime(2016, 5, 18, 10, 0, tzinfo=pytz.utc) @@ -257,8 +137,10 @@ the link. :: my_episode.link = "http://example.com/article/2016/05/18/Best-example" -If you don't have anything to link to, then that's fine as well. No link is -better than a disappointing link. +.. note:: + + If you don't have anything to link to, then that's fine as well. No link is + better than a disappointing link. .. autosummary:: ~feedgen.item.BaseEpisode.link @@ -266,15 +148,20 @@ better than a disappointing link. The Author ^^^^^^^^^^ -There is no point in copy+pasting the same author name in every single episode. -Instead, you should just set :attr:`Podcast.managingEditor ` -to the appropriate name and/or email address, and don't set any authors on the -episodes. iTunes and others are smart enough to understand that the person -or entity named in :attr:`Podcast.managingEditor ` -is responsible for all episodes. +.. note:: + + Some of those attributes correspond to attributes found in + :class:`~feedgen.feed.Podcast`. In such cases, you should only set those + attributes at the episode level if they **differ** from their value at the + podcast level. -If the author of an episode differs from the rest, though, you can use -:attr:`the author attribute ` to indicate that:: +Normally, the attributes :attr:`Podcast.managingEditor ` +and :attr:`Podcast.webMaster ` (if set) are +used to determine the author of an episode. Thus, if all your episodes have +the same author, you should just set it at the podcast level. + +If an episode's author differs from the podcast's, though, you can override it +like this: my_episode.author = Person("Joe Bob") @@ -315,37 +202,4 @@ Less used attributes ~feedgen.item.BaseEpisode.order ~feedgen.item.BaseEpisode.withhold_from_itunes -Generating the RSS ------------------- - -Once you've added all the information and all episodes, it's time to -take the final step:: - - rssfeed = p.rss_str() - # Print to stdout, just as an example - print(rssfeed) - -If you're okay with the default parameters of :meth:`feedgen.feed.Podcast.rss_str`, -you can use a shortcut by converting :class:`~feedgen.feed.Podcast` to :obj:`str`:: - - rssfeed = str(p) - # Or let print convert to str for you - print(p) - -Doing so is the same as calling :meth:`feedgen.feed.Podcast.rss_str` with no -parameters. - -.. autosummary:: - - ~feedgen.feed.Podcast.rss_str - -You may also write the feed to a file directly, using :meth:`feedgen.feed.Podcast.rss_file`:: - - fg.rss_file('rss.xml', minimize=True) - - -.. autosummary:: - - ~feedgen.feed.Podcast.rss_file - - +The final step is :doc:`part_3` diff --git a/doc/user/basic_usage_guide/part_3.rst b/doc/user/basic_usage_guide/part_3.rst new file mode 100644 index 0000000..828a71e --- /dev/null +++ b/doc/user/basic_usage_guide/part_3.rst @@ -0,0 +1,36 @@ + +Generating the RSS +------------------ + +Once you've added all the information and all episodes, it's time to +take the final step:: + + rssfeed = p.rss_str() + # Print to stdout, just as an example + print(rssfeed) + +If you're okay with the default parameters of :meth:`feedgen.feed.Podcast.rss_str`, +you can use a shortcut by converting :class:`~feedgen.feed.Podcast` to :obj:`str`:: + + rssfeed = str(p) + # Or let print convert to str for you + print(p) + +Doing so is the same as calling :meth:`feedgen.feed.Podcast.rss_str` with no +parameters. + +.. autosummary:: + + ~feedgen.feed.Podcast.rss_str + +You may also write the feed to a file directly, using :meth:`feedgen.feed.Podcast.rss_file`:: + + fg.rss_file('rss.xml', minimize=True) + + +.. autosummary:: + + ~feedgen.feed.Podcast.rss_file + +This concludes the basic usage guide. You might want to look at the +:doc:`../example` or the :doc:`/api`. diff --git a/doc/user/example.rst b/doc/user/example.rst index 37bba43..131110d 100644 --- a/doc/user/example.rst +++ b/doc/user/example.rst @@ -10,5 +10,5 @@ also shows you how you can use the different properties of Podcast and Episode. :linenos: Once you understand the basic way you do things, you're ready to look at the -:doc:`/api` in conjunction with the :doc:`use` to see exactly what properties you can set, and how they +:doc:`/api` in conjunction with the :doc:`basic_usage_guide/index` to see exactly what properties you can set, and how they affect the end result. diff --git a/doc/user/index.rst b/doc/user/index.rst index eb17e48..23ad3c3 100644 --- a/doc/user/index.rst +++ b/doc/user/index.rst @@ -11,6 +11,6 @@ came to be, its license as well as how to install and start using it. introduction fork - installing - use + installation + basic_usage_guide/index example diff --git a/doc/user/installing.rst b/doc/user/installation.rst similarity index 100% rename from doc/user/installing.rst rename to doc/user/installation.rst diff --git a/doc/user/introduction.rst b/doc/user/introduction.rst index a3abf49..476a8ac 100644 --- a/doc/user/introduction.rst +++ b/doc/user/introduction.rst @@ -8,15 +8,15 @@ Philosophy ---------- This project is heavily inspired by the wonderful -`Kenneth Reitz `_, known for the -`Requests `_ library, which features an API which is +`Kenneth Reitz `__, known for the +`Requests `__ library, which features an API which is as beautiful as it is effective. Watching his -`"Documentation is King" talk `_, +`"Documentation is King" talk `__, I wanted to make some of the libraries I'm using suitable for use by actual humans. This project is to be developed following the same -`PEP 20 `_ idioms as -`Requests `_: +`PEP 20 `__ idioms as +`Requests `__: 1. Beautiful is better than ugly. 2. Explicit is better than implicit. @@ -44,7 +44,8 @@ documentation for iTunes' Podcast Connect. PodcastGenerator is geared towards developers who aren't super familiar with RSS and XML. If you know exactly how you want the XML to look, then you're better off using a template engine like Jinja2 (even if friends don't let -friends touch XML bare-handed). +friends touch XML bare-handed). If you just want an easy way to create and +manage your podcasts, use `Podcast Generator `. ------- License diff --git a/feedgen/item.py b/feedgen/item.py index 7f43bcc..2723118 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -224,7 +224,7 @@ def summary(self, new_summary=None, html=True): the "circled i" in the Description column is clicked. This field can be up to 4000 characters in length. - See also :py:meth:`.itunes_subtitle`. + See also :py:meth:`.BaseEpisode.itunes_subtitle`. :param new_summary: The summary of this episode. :param html: Treat the summary as HTML. If set to False, the summary From 4a96e3e5c687023068f7993becfea417f28e14b5 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 30 Jun 2016 13:22:13 +0200 Subject: [PATCH 053/200] Merge itunes_author with author --- feedgen/__main__.py | 3 +-- feedgen/feed.py | 1 + feedgen/item.py | 44 ++++++++++++++-------------------- feedgen/tests/test_entry.py | 47 +++++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index a410089..d433217 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -24,7 +24,7 @@ def print_enc(s): def main(): - """Create an example podcast and, print it or save it to file.""" + """Create an example podcast and print it or save it to a file.""" # There must be exactly one argument, and it is must end with rss if len(sys.argv) != 2 or not ( sys.argv[1].endswith('rss')): @@ -70,7 +70,6 @@ def main(): verba <3.''', html=False) e1.link(href='http://example.com') e1.author(name='Lars Kiesow', email='lkiesow@uos.de') - e1.itunes_author('Lars Kiesow') e1.published(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) # Should we just print out, or write to file? diff --git a/feedgen/feed.py b/feedgen/feed.py index b7c4838..66dba73 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -192,6 +192,7 @@ def _create_rss(self): 'atom': 'http://www.w3.org/2005/Atom', 'content': 'http://purl.org/rss/1.0/modules/content/', 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd', + 'dc': 'http://purl.org/dc/elements/1.1/' } ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' diff --git a/feedgen/item.py b/feedgen/item.py index 2723118..7879be8 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -38,7 +38,6 @@ 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 @@ -50,6 +49,10 @@ def __init__(self): def rss_entry(self, extensions=True): """Create a RSS item and return it.""" + + ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' + DUBLIN_NS = 'http://purl.org/dc/elements/1.1/' + entry = etree.Element('item') if not ( self.__rss_title or self.__rss_content): @@ -69,9 +72,20 @@ def rss_entry(self, extensions=True): content = etree.SubElement(entry, '{%s}encoded' % 'http://purl.org/rss/1.0/modules/content/') content.text = etree.CDATA(self.__rss_content) - for a in self.__rss_author or []: - author = etree.SubElement(entry, 'author') - author.text = a + + if self.__rss_author: + if len(self.__rss_author) > 1: + author = etree.SubElement(entry, '{%s}author' % ITUNES_NS) + author.text = self.__rss_author[-1] + + # Use dc:creator + for a in self.__rss_author or []: + author = etree.SubElement(entry, '{%s}creator' % DUBLIN_NS) + author.text = a + else: + # Only one author, use rss author + author = etree.SubElement(entry, 'author') + author.text = self.__rss_author[0] if self.__rss_guid: rss_guid = self.__rss_guid @@ -95,13 +109,6 @@ def rss_entry(self, extensions=True): pubDate = etree.SubElement(entry, 'pubDate') pubDate.text = formatRFC2822(self.__rss_pubDate) - # Itunes fields - 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' @@ -297,21 +304,6 @@ def enclosure(self, url=None, length=None, type=None): self.__rss_enclosure = {'url': url, 'length': length, 'type': type} return self.__rss_enclosure - 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. - :type itunes_author: str - :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. Note that the episode can still be diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index e5fedbe..76d4d71 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -17,6 +17,9 @@ class TestSequenceFunctions(unittest.TestCase): def setUp(self): + self.itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' + self.dublin_ns = 'http://purl.org/dc/elements/1.1/' + fg = Podcast() self.title = 'Some Testfeed' self.link = 'http://lernfunk.de' @@ -29,6 +32,7 @@ def setUp(self): fe = fg.add_episode() fe.id('http://lernfunk.de/media/654321/1') fe.title('The First Episode') + self.fe = fe #Use also the list directly fe = fg.Episode() @@ -148,3 +152,46 @@ def test_feedPubDateDisabled(self): self.fg.published(False) pubDate = self.fg._create_rss().find("channel").find("pubDate") assert pubDate is None # Not found! + + def test_oneAuthor(self): + name = "John Doe" + email = "johndoe@example.org" + self.fe.author(name=name, email=email) + author_text = self.fe.rss_entry().find("author").text + assert name in author_text + assert email in author_text + + # Test that itunes:author is not used when rss author does the same job + assert self.fe.rss_entry().find("{%s}author" % self.itunes_ns) is None + + # Test that dc:creator is not used when rss author does the same job + assert self.fe.rss_entry().find("{%s}creator" % self.dublin_ns) is None + + def test_multipleAuthors(self): + name1 = "John Doe" + email1 = "johndoe@example.org" + name2 = "Mary Sue" + email2 = "marysue@example.org" + + self.fe.author([{'name': name1, 'email': email1}, + {'name': name2, 'email': email2}]) + author_elements = \ + self.fe.rss_entry().findall("{%s}creator" % self.dublin_ns) + author_texts = [e.text for e in author_elements] + + # Test that both authors are included, in the same order they were added + assert name1 in author_texts[0] + assert email1 in author_texts[0] + assert name2 in author_texts[1] + assert email2 in author_texts[1] + + # Test that itunes:author is the last author + itunes_author = \ + self.fe.rss_entry().find("{%s}author" % self.itunes_ns).text + assert name1 not in itunes_author + assert email1 not in itunes_author + assert name2 in itunes_author + assert email2 in itunes_author + + # Test that the regular rss tag is not used, per the RSS recommendations + assert self.fe.rss_entry().find("author") is None From 8f09d3ee2cb89e840ed96127c7369662bf3ad3ee Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 1 Jul 2016 00:46:04 +0200 Subject: [PATCH 054/200] Change from dict to Person All attributes/methods which accept a person (be it a real human or an organization entity), now require you to use an instance of feedgen.person.Person. This is done to make it obvious to users that an email and/or a name is to be used, and help prevent previously uncaught errors if the user misspelled a dictionary key. This also makes all such attributes compatible with one another, allowing you to assign one attribute's value to another. On the feed level, the managingEditor and itunes:author have been combined, and support for dc:creator is added, as the RSS Best Practices recommend. This also means the API is changed, since all those three are set using Podcast.author. Note that Podcast does not support multiple authors, even though dc:creator provides support for it. This is planned for a future commit. The way you assign multiple authors to an Episode has changed, though. Now, authors are given as varargs argument, meaning you have to unpack your list of authors. As a sidenote, the recipe for "make test" has been modified to ignore errors. Errors will still be printed, but this way, all test classes will be run each time, so you can see all the errors at once (instead of only seeing the errors from the first failing class). --- Makefile | 8 +- doc/api.person.rst | 6 ++ doc/api.rst | 4 + doc/user/basic_usage_guide/part_1.rst | 6 +- doc/user/basic_usage_guide/part_2.rst | 4 +- feedgen/__main__.py | 8 +- feedgen/feed.py | 104 +++++++++++++------------- feedgen/item.py | 88 ++++++++++++---------- feedgen/person.py | 85 +++++++++++++++++++++ feedgen/tests/test_entry.py | 66 +++++++++++----- feedgen/tests/test_feed.py | 77 ++++++++++++++++--- feedgen/tests/test_person.py | 76 +++++++++++++++++++ feedgen/tests/test_util.py | 26 +++++++ feedgen/util.py | 12 +++ 14 files changed, 438 insertions(+), 132 deletions(-) create mode 100644 doc/api.person.rst create mode 100644 feedgen/person.py create mode 100644 feedgen/tests/test_person.py create mode 100644 feedgen/tests/test_util.py diff --git a/Makefile b/Makefile index fd3c8d8..a8b3383 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,9 @@ 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 feedgen rss > /dev/null + -python -m unittest feedgen.tests.test_feed + -python -m unittest feedgen.tests.test_entry + -python -m unittest feedgen.tests.test_person + -python -m unittest feedgen.tests.test_util + -python -m feedgen rss > /dev/null @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml diff --git a/doc/api.person.rst b/doc/api.person.rst new file mode 100644 index 0000000..a2a0312 --- /dev/null +++ b/doc/api.person.rst @@ -0,0 +1,6 @@ +===================== +feedgen.person.Person +===================== + +.. autoclass:: feedgen.person.Person + :members: diff --git a/doc/api.rst b/doc/api.rst index 93e7601..b778240 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -31,6 +31,9 @@ to make. When you create episodes for a Podcast, you're most likely creating new instances of :class:`feedgen.item.BaseEpisode`. +You use :class:`feedgen.person.Person` whenever an attribute is to represent +a person or an entity. + :mod:`feedgen.util` provides utility functions for the rest of the library, and is therefore not relevant for users. @@ -41,4 +44,5 @@ and is therefore not relevant for users. api.feed api.item + api.person api.util diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index 38d1507..a59f01e 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -56,17 +56,17 @@ Commonly used p.copyright = "© 2016 Example Radio" p.language = "en-US" - p.managingEditor = p.Person("John Doe", "editor@example.org") + p.author = p.Person("John Doe", "editor@example.org") p.feed_url = "https://example.com/feeds/podcast.rss" p.category = Category("Technology", "Podcasting") p.explicit = True - p.owner = p.managingEditor + p.owner = p.author .. autosummary:: ~feedgen.feed.Podcast.copyright ~feedgen.feed.Podcast.language - ~feedgen.feed.Podcast.managingEditor + ~feedgen.feed.Podcast.author ~feedgen.feed.Podcast.feed_url ~feedgen.feed.Podcast.category ~feedgen.feed.Podcast.explicit diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index c55e36e..a48cdbc 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -155,13 +155,13 @@ The Author attributes at the episode level if they **differ** from their value at the podcast level. -Normally, the attributes :attr:`Podcast.managingEditor ` +Normally, the attributes :attr:`Podcast.author ` and :attr:`Podcast.webMaster ` (if set) are used to determine the author of an episode. Thus, if all your episodes have the same author, you should just set it at the podcast level. If an episode's author differs from the podcast's, though, you can override it -like this: +like this:: my_episode.author = Person("Joe Bob") diff --git a/feedgen/__main__.py b/feedgen/__main__.py index d433217..7806fb3 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -43,21 +43,21 @@ def main(): arg = sys.argv[1] from feedgen.feed import Podcast + from feedgen.person import Person # Initialize the feed p = Podcast() p.name('Testfeed') - p.managingEditor('lkiesow@uos.de (Lars Kiesow)') + p.author(Person("Lars Kiesow", "lkiesow@uos.de")) p.website(href='http://example.com') p.copyright('cc-by') p.description('This is a cool feed!') p.language('de') p.feed_url('http://example.com/feeds/myfeed.rss') - p.itunes_author('Lars Kiesow') p.itunes_category('Technology', 'Podcasting') p.itunes_explicit('no') p.itunes_complete('no') p.itunes_new_feed_url('http://example.com/new-feed.rss') - p.itunes_owner('John Doe', 'john@example.com') + p.itunes_owner(Person('John Doe', 'john@example.com')) e1 = p.add_episode() e1.id('http://lernfunk.de/_MEDIAID_123#1') @@ -69,7 +69,7 @@ def main(): occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, verba <3.''', html=False) e1.link(href='http://example.com') - e1.author(name='Lars Kiesow', email='lkiesow@uos.de') + e1.author(Person('Lars Kiesow', 'lkiesow@uos.de')) e1.published(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) # Should we just print out, or write to file? diff --git a/feedgen/feed.py b/feedgen/feed.py index 66dba73..0e59ddd 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -15,6 +15,7 @@ import dateutil.tz from feedgen.item import BaseEpisode from feedgen.util import ensure_format, formatRFC2822 +from feedgen.person import Person import feedgen.version import sys from feedgen.compat import string_types @@ -50,7 +51,7 @@ def __init__(self): self.__rss_generator = self._feedgen_generator_str self.__rss_language = None self.__rss_lastBuildDate = None - self.__rss_managingEditor = None + self.__rss_author = None self.__rss_pubDate = None self.__rss_skipHours = None self.__rss_skipDays = None @@ -60,7 +61,6 @@ 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 @@ -240,9 +240,17 @@ def _create_rss(self): lastBuildDate = etree.SubElement(channel, 'lastBuildDate') lastBuildDate.text = formatRFC2822(lastBuildDateDate) - if self.__rss_managingEditor: - managingEditor = etree.SubElement(channel, 'managingEditor') - managingEditor.text = self.__rss_managingEditor + if self.__rss_author: + if self.__rss_author.email: + managingEditor = etree.SubElement(channel, 'managingEditor') + managingEditor.text = str(self.__rss_author) + else: + creator = etree.SubElement(channel, '{%s}creator' % nsmap['dc']) + creator.text = str(self.__rss_author) + if self.__rss_author.name: + itunes_author = etree.SubElement(channel, + '{%s}author' % ITUNES_NS) + itunes_author.text = self.__rss_author.name if self.__rss_pubDate is None: episode_dates = [e.published() for e in self.episodes if e.published() is not None] @@ -267,12 +275,12 @@ def _create_rss(self): day = etree.SubElement(skipDays, 'day') day.text = d if self.__rss_webMaster: + if not self.__rss_webMaster.email: + raise RuntimeError("webMaster must have an email. Did you " + "set email to None after assigning that " + "Person to webMaster?") webMaster = etree.SubElement(channel, 'webMaster') - webMaster.text = self.__rss_webMaster - - if self.__itunes_author: - author = etree.SubElement(channel, '{%s}author' % ITUNES_NS) - author.text = self.__itunes_author + webMaster.text = str(self.__rss_webMaster) if not self.__itunes_block is None: block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) @@ -304,9 +312,9 @@ def _create_rss(self): 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_name.text = self.__itunes_owner.name owner_email = etree.SubElement(owner, '{%s}email' % ITUNES_NS) - owner_email.text = self.__itunes_owner.get('email') + owner_email.text = self.__itunes_owner.email if self.__itunes_subtitle: subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) @@ -548,16 +556,21 @@ def language(self, language=None): 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. + def author(self, author=None): + """Set or get which person or entity is responsible for the podcast's + editorial content. + + This person's name, if supplied, is shown on iTunes under the podcast's + title. Otherwise, the email is used. - :param managingEditor: Email adress of the managing editor. - :returns: Email adress of the managing editor. + :param author: The person or entity responsible for editorial + content. + :type author: feedgen.person.Person + :returns: The person or entity responsible for editorial content. """ - if not managingEditor is None: - self.__rss_managingEditor = managingEditor - return self.__rss_managingEditor + if author is not None: + self.__rss_author = author + return self.__rss_author def published(self, pubDate=None): @@ -653,30 +666,22 @@ def skipDays(self, days=None, replace=False): 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. - - :param webMaster: Email address of the webmaster. - :returns: Email address of the webmaster. + """Get and set the person responsible for technical issues relating to + the feed. + + :param webMaster: The person responsible for technical issues relating + to the feed. This instance of Person must have its email set. + :type webMaster: Person + :returns: The person responsible for technical issues relating to the + feed. """ - if not webMaster is None: + if webMaster is not None: + if (not hasattr(webMaster, "email")) or not webMaster.email: + raise ValueError("The webmaster must have an email attribute " + "and it must be set and not empty.") self.__rss_webMaster = webMaster return self.__rss_webMaster - 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. - :type itunes_author: str - :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. @@ -835,7 +840,7 @@ def itunes_new_feed_url(self, itunes_new_feed_url=None): self.__itunes_new_feed_url = itunes_new_feed_url return self.__itunes_new_feed_url - def itunes_owner(self, name=None, email=None): + def itunes_owner(self, owner): """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 @@ -843,19 +848,14 @@ def itunes_owner(self, name=None, email=None): Both the name and email are required; you cannot use one or the other alone. - :param name: The name of the owner of the feed. - :type name: str - :param email: The feed owner's email. - :type email: str - :returns: Data of the owner of the feed. + :param owner: The person which iTunes will contact when needed. + :returns: The owner of this feed, which iTunes will contact when needed. """ - 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 + if owner is not None: + if owner.name and owner.email: + self.__itunes_owner = owner else: - raise ValueError('Both name and email have to be set.') + raise ValueError('Both name and email must be set.') return self.__itunes_owner def itunes_subtitle(self, itunes_subtitle=None): diff --git a/feedgen/item.py b/feedgen/item.py index 7879be8..f9c2be5 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -13,7 +13,8 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.util import ensure_format, formatRFC2822, htmlencode +from feedgen.util import ensure_format, formatRFC2822, htmlencode, \ + listToHumanreadableStr from feedgen.compat import string_types from builtins import str @@ -74,18 +75,28 @@ def rss_entry(self, extensions=True): content.text = etree.CDATA(self.__rss_content) if self.__rss_author: - if len(self.__rss_author) > 1: - author = etree.SubElement(entry, '{%s}author' % ITUNES_NS) - author.text = self.__rss_author[-1] - - # Use dc:creator + authors_with_name = [a.name for a in self.__rss_author if a.name] + if authors_with_name: + # We have something to display as itunes:author, combine all + # names + itunes_author = \ + etree.SubElement(entry, '{%s}author' % ITUNES_NS) + itunes_author.text = listToHumanreadableStr(authors_with_name) + if len(self.__rss_author) > 1 or not self.__rss_author[0].email: + # Use dc:creator, since it supports multiple authors (and + # author without email) for a in self.__rss_author or []: author = etree.SubElement(entry, '{%s}creator' % DUBLIN_NS) - author.text = a + if a.name and a.email: + author.text = "%s <%s>" % (a.name, a.email) + elif a.name: + author.text = a.name + else: + author.text = a.email else: - # Only one author, use rss author + # Only one author and with email, so use rss author author = etree.SubElement(entry, 'author') - author.text = self.__rss_author[0] + author.text = str(self.__rss_author[0]) if self.__rss_guid: rss_guid = self.__rss_guid @@ -184,43 +195,42 @@ def id(self, new_id=None): return self.__rss_guid - def author(self, author=None, replace=False, **kwargs): - """Get or set autor data. An author element is a dict containing a name and - an email adress. Email is mandatory. - - 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 + def author(self, *authors, replace=False): + """Get or append to the list of Person that contributed to this episode. - An author has the following fields: - - *name* conveys a human-readable name 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. + The authors don't need to have both name and email set. 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'}] - + >>> ep.author(Person("John Doe", "johndoe@example.org")) + + >>> # Multiple authors can be given as separate parameters + >>> ep.author(Person("John Doe", "johndoe@example.org"), + ... Person("Mary Sue", "marysue@example.org"), replace=True) + >>> # Or as one unpacked list (just passing a list is an error) + >>> ep.author(*[Person("John Doe", "johndoe@example.org"), + ... Person("Mary Sue", "marysue@example.org")], + ... replace=True) + + :param authors: One or more Person objects that will be added to the + list of authors for this episode. Lists are not accepted, they + must be unpacked first (see example). + :type authors: list or Person + :param replace: Set to True to start over from an empty list. + :type replace: bool + :returns: The current list of authors. """ - if author is None and kwargs: - author = kwargs - if not author is None: + if not authors is None: + # Check that the authors quack like ducks + for a in authors: + if not (hasattr(a, "name") and hasattr(a, "email")): + raise TypeError("Author parameter %s does not have the " + "attributes name and/or email. You " + "didn't forget to unpack a list?" % a) + if replace or self.__rss_author is None: self.__rss_author = [] - authors = ensure_format( author, - set(['name', 'email']), set(['email'])) - self.__rss_author += ['%s (%s)' % ( a['email'], a['name'] ) for a in authors] + self.__rss_author.extend(authors) return self.__rss_author diff --git a/feedgen/person.py b/feedgen/person.py new file mode 100644 index 0000000..885f4d5 --- /dev/null +++ b/feedgen/person.py @@ -0,0 +1,85 @@ +class Person(object): + """Class representing a single person or entity. + + .. note:: + + At any time, one of name or email must be present. + Both cannot be None or empty at the same time. + + Example:: + + >>> from feedgen.person import Person + >>> Person("John Doe") + Person(name=John Doe, email=None) + >>> Person(email="johndoe@example.org") + Person(name=None, email=johndoe@example.org) + >>> Person() + ValueError: You must provide either a name or an email address. + + """ + + def __init__(self, name=None, email=None): + """Create new person with a name, email or both. + + A Person can represent both real persons and organizations, entities + and so on. Example:: + + >>> p.managingEditor = Person("Example Radio", "mail@example.org") + + You don't need to provide both a name and an email, but you must + provide one of them. + + :param name: This person's name. + :type name: str or None + :param email: This person's email address. The address it made public + when the feed is published, so be careful about adding a personal + email address here. The spambots are always on lookout! + :type email: str or None + + """ + if not self._is_valid(name, email): + raise ValueError("You must provide either a name or an email " + "address.") + self.__name = name + self.__email = email + + def _is_valid(self, name, email): + """Check whether one of name and email are usable.""" + return name or email + + @property + def name(self): + """This person's name.""" + return self.__name + + @name.setter + def name(self, new_name): + if not self._is_valid(new_name, self.email): + raise ValueError("The name or email must be present at any time, " + "cannot set name to \"%s\" as long as email is " + "\"%s\"" % (new_name, self.email)) + self.__name = new_name + + @property + def email(self): + """This person's public email address.""" + return self.__email + + @email.setter + def email(self, new_email): + if not self._is_valid(self.name, new_email): + raise ValueError("The name or email must be present at any time, " + "cannot set email to \"%s\" as long as name is " + "\"%s\"" % (new_email, self.name)) + self.__email = new_email + + def __str__(self): + if self.email is None: + return self.name + elif self.name is None: + return self.email + else: + return "%s (%s)" % (self.email, self.name) + + def __repr__(self): + return "Person(name=%s, email=%s)" % (self.name, self.email) diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 76d4d71..b9198cc 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -8,6 +8,8 @@ import unittest from lxml import etree + +from feedgen.person import Person from ..feed import Podcast import datetime import pytz @@ -156,42 +158,68 @@ def test_feedPubDateDisabled(self): def test_oneAuthor(self): name = "John Doe" email = "johndoe@example.org" - self.fe.author(name=name, email=email) + self.fe.author(Person(name, email)) author_text = self.fe.rss_entry().find("author").text assert name in author_text assert email in author_text - # Test that itunes:author is not used when rss author does the same job - assert self.fe.rss_entry().find("{%s}author" % self.itunes_ns) is None - + # Test that itunes:author is the name + assert self.fe.rss_entry().find("{%s}author" % self.itunes_ns).text\ + == name # Test that dc:creator is not used when rss author does the same job assert self.fe.rss_entry().find("{%s}creator" % self.dublin_ns) is None + def test_oneAuthorWithoutEmail(self): + name = "John Doe" + self.fe.author(Person(name)) + entry = self.fe.rss_entry() + + # Test that author is not used, since it requires email + assert entry.find("author") is None + # Test that itunes:author is still the name + assert entry.find("{%s}author" % self.itunes_ns).text == name + # Test that dc:creator is used in rss author's place (since dc:creator + # doesn't require email) + assert entry.find("{%s}creator" % self.dublin_ns).text == name + + def test_oneAuthorWithoutName(self): + email = "johndoe@example.org" + self.fe.author(Person(email=email)) + entry = self.fe.rss_entry() + + # Test that rss author is the email + assert entry.find("author").text == email + # Test that itunes:author is not used, since it requires name + assert entry.find("{%s}author" % self.itunes_ns) is None + # Test that dc:creator is not used, since it would duplicate rss author + assert entry.find("{%s}creator" % self.dublin_ns) is None + + def test_multipleAuthors(self): - name1 = "John Doe" - email1 = "johndoe@example.org" - name2 = "Mary Sue" - email2 = "marysue@example.org" + person1 = Person("John Doe", "johndoe@example.org") + person2 = Person("Mary Sue", "marysue@example.org") - self.fe.author([{'name': name1, 'email': email1}, - {'name': name2, 'email': email2}]) + # Check that an error occurs if a list is given + self.assertRaises(TypeError, self.fe.author, [person1, person2]) + # Do it properly this time + self.fe.author(*[person1, person2]) author_elements = \ self.fe.rss_entry().findall("{%s}creator" % self.dublin_ns) author_texts = [e.text for e in author_elements] # Test that both authors are included, in the same order they were added - assert name1 in author_texts[0] - assert email1 in author_texts[0] - assert name2 in author_texts[1] - assert email2 in author_texts[1] + assert person1.name in author_texts[0] + assert person1.email in author_texts[0] + assert person2.name in author_texts[1] + assert person2.email in author_texts[1] - # Test that itunes:author is the last author + # Test that itunes:author includes all authors' name, but not email itunes_author = \ self.fe.rss_entry().find("{%s}author" % self.itunes_ns).text - assert name1 not in itunes_author - assert email1 not in itunes_author - assert name2 in itunes_author - assert email2 in itunes_author + assert person1.name in itunes_author + assert person1.email not in itunes_author + assert person2.name in itunes_author + assert person2.email not in itunes_author # Test that the regular rss tag is not used, per the RSS recommendations assert self.fe.rss_entry().find("author") is None diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index f412e51..9034961 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -9,6 +9,8 @@ import unittest from lxml import etree + +from feedgen.person import Person from ..feed import Podcast import feedgen.version import datetime @@ -21,13 +23,14 @@ def setUp(self): fg = Podcast() - self.nsRss = "http://purl.org/rss/1.0/modules/content/" + self.nsContent = "http://purl.org/rss/1.0/modules/content/" + self.nsDc = "http://purl.org/dc/elements/1.1/" + self.nsItunes = "http://www.itunes.com/dtds/podcast-1.0.dtd" self.feedUrl = "http://example.com/feeds/myfeed.rss" self.title = 'Some Testfeed' - self.authorName = 'John Doe' - self.authorMail = 'john@example.de' + self.author = Person('John Doe', 'john@example.de') self.linkHref = 'http://example.com' self.description = 'This is a cool feed!' @@ -43,13 +46,12 @@ def setUp(self): self.contributor = {'name':"Contributor Name", 'email': 'Contributor email'} self.copyright = "The copyright notice" self.docs = 'http://www.rssboard.org/rss-specification' - self.managingEditor = 'mail@example.com' self.skipDays = 'Tuesday' self.skipHours = 23 self.programname = feedgen.version.name - self.webMaster = 'webmaster@example.com' + self.webMaster = Person(email='webmaster@example.com') fg.name(self.title) fg.website(href=self.linkHref) @@ -59,7 +61,7 @@ def setUp(self): path=self.cloudPath, registerProcedure=self.cloudRegisterProcedure, protocol=self.cloudProtocol) fg.copyright(self.copyright) - fg.managingEditor(self.managingEditor) + fg.author(self.author) fg.skipDays(self.skipDays) fg.skipHours(self.skipHours) fg.webMaster(self.webMaster) @@ -73,7 +75,7 @@ def test_baseFeed(self): assert fg.name() == self.title - assert fg.managingEditor() == self.managingEditor + assert fg.author() == self.author assert fg.webMaster() == self.webMaster assert fg.website() == self.linkHref @@ -103,7 +105,7 @@ def test_rssFeedString(self): def checkRssString(self, rssString): feed = etree.fromstring(rssString) - nsRss = self.nsRss + nsRss = self.nsContent nsAtom = "http://www.w3.org/2005/Atom" channel = feed.find("channel") @@ -121,10 +123,10 @@ def checkRssString(self, rssString): 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 self.author.email in channel.find("managingEditor").text assert channel.find("skipDays").find("day").text == self.skipDays assert int(channel.find("skipHours").find("hour").text) == self.skipHours - assert channel.find("webMaster").text == self.webMaster + assert self.webMaster.email in channel.find("webMaster").text assert channel.find("{%s}link" % nsAtom).get('href') == self.feedUrl assert channel.find("{%s}link" % nsAtom).get('rel') == 'self' assert channel.find("{%s}link" % nsAtom).get('type') == \ @@ -173,5 +175,60 @@ def getLastBuildDateElement(fg): lastBuildDate = getLastBuildDateElement(self.fg) assert lastBuildDate is None + def test_AuthorEmail(self): + # Just email - so use managingEditor, not dc:creator or itunes:author + # This is per the RSS best practices, see the section about dc:creator + self.fg.author(Person(None, "justan@email.address")) + channel = self.fg._create_rss().find("channel") + # managingEditor uses email? + assert channel.find("managingEditor").text == self.fg.author().email + # No dc:creator? + assert channel.find("{%s}creator" % self.nsDc) is None + # No itunes:author? + assert channel.find("{%s}author" % self.nsItunes) is None + + def test_AuthorName(self): + # Just name - use dc:creator and itunes:author, not managingEditor + self.fg.author(Person("Just a. Name")) + channel = self.fg._create_rss().find("channel") + # No managingEditor? + assert channel.find("managingEditor") is None + # dc:creator equals name? + assert channel.find("{%s}creator" % self.nsDc).text == \ + self.fg.author().name + # itunes:author equals name? + assert channel.find("{%s}author" % self.nsItunes).text == \ + self.fg.author().name + + def test_AuthorNameAndEmail(self): + # Both name and email - use managingEditor and itunes:author, + # not dc:creator + self.fg.author(Person("Both a name", "and_an@email.com")) + channel = self.fg._create_rss().find("channel") + # Does managingEditor follow the pattern "email (name)"? + self.assertEqual(self.fg.author().email + + " (" + self.fg.author().name + ")", + channel.find("managingEditor").text) + # No dc:creator? + assert channel.find("{%s}creator" % self.nsDc) is None + # itunes:author uses name only? + assert channel.find("{%s}author" % self.nsItunes).text == \ + self.fg.author().name + + def test_webMaster(self): + self.fg.webMaster(Person(None, "justan@email.address")) + channel = self.fg._create_rss().find("channel") + assert channel.find("webMaster").text == self.fg.webMaster().email + + self.assertRaises(ValueError, self.fg.webMaster, + Person("Mr. No Email Address")) + + self.fg.webMaster(Person("Both a name", "and_an@email.com")) + channel = self.fg._create_rss().find("channel") + # Does webMaster follow the pattern "email (name)"? + self.assertEqual(self.fg.webMaster().email + + " (" + self.fg.webMaster().name + ")", + channel.find("webMaster").text) + if __name__ == '__main__': unittest.main() diff --git a/feedgen/tests/test_person.py b/feedgen/tests/test_person.py new file mode 100644 index 0000000..7d444d8 --- /dev/null +++ b/feedgen/tests/test_person.py @@ -0,0 +1,76 @@ +import unittest +from ..person import Person + +class TestSequenceFunctions(unittest.TestCase): + def setUp(self): + self.name = "Test Person" + self.email = "test@example.org" + + def _person(self): + return Person(self.name, self.email) + + def test_noName(self): + p = Person(None, self.email) + assert p.name is None + assert p.email == self.email + + def test_noEmail(self): + p = Person(self.name) + assert p.name == self.name + assert p.email is None + + def test_bothNameAndEmail(self): + p = Person(self.name, self.email) + assert p.name == self.name + assert p.email == self.email + + def test_settingName(self): + other_name = "Mary Sue" + p = self._person() + p.name = other_name + assert p.name == other_name + assert p.email == self.email + + p.name = None + assert p.name is None + assert p.email == self.email + + def test_settingEmail(self): + other_email = "noreply@example.org" + p = self._person() + p.email = other_email + assert p.name == self.name + assert p.email == other_email + + p.email = None + assert p.name == self.name + assert p.email is None + + def test_invalidConstruction(self): + self.assertRaises(ValueError, Person) + + def test_invalidSettingName(self): + p = Person(self.name) + self.assertRaises(ValueError, setattr, p, "name", None) + assert p.name == self.name + + def test_invalidSettingEmail(self): + p = Person(email=self.email) + self.assertRaises(ValueError, setattr, p, "email", None) + assert p.email == self.email + + def test_changingFromNameToMail(self): + p = Person(name=self.name) + p.email = self.email + p.name = None + + assert p.email == self.email + assert p.name is None + + def test_changingFromMailToName(self): + p = Person(email=self.email) + p.name = self.name + p.email = None + + assert p.name == self.name + assert p.email is None diff --git a/feedgen/tests/test_util.py b/feedgen/tests/test_util.py new file mode 100644 index 0000000..2f0f9b2 --- /dev/null +++ b/feedgen/tests/test_util.py @@ -0,0 +1,26 @@ +import unittest +from feedgen import util + +class TestSequenceFunctions(unittest.TestCase): + + def test_listToHumanReadableStr(self): + # Just check that none of the cases causes an error + empty = util.listToHumanreadableStr([]) + one = util.listToHumanreadableStr([4]) + two = util.listToHumanreadableStr([4, "hi"]) + three = util.listToHumanreadableStr([4, "hi", "low"]) + + assert "4" in one + assert "and" not in one + assert "," not in one + + assert "4" in two + assert "and" in two + assert "hi" in two + assert "," not in two + + assert "4" in three + assert "," in three + assert "hi" in three + assert "and" in three + assert "low" in three diff --git a/feedgen/util.py b/feedgen/util.py index a3c7661..50fc14b 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -85,3 +85,15 @@ def htmlencode(s): def htmlencode(s): return html.escape(s) + +def listToHumanreadableStr(l): + # TODO: Allow translations of "and" and "empty" + length = len(l) + l = [str(e) for e in l] + + if length == 0: + return "(empty)" + elif length == 1: + return l[0] + else: + return ", ".join(l[:-1]) + " and " + l[-1] From b7538e9fbcf7e245fd50aa982fb8a12b003ad69a Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 1 Jul 2016 01:46:38 +0200 Subject: [PATCH 055/200] Allow multiple authors in Podcast as well It's a bit sad to copy+paste the code from BaseEpisode, but I can't really think of a better design right now. --- feedgen/feed.py | 84 +++++++++++++++++++++++++++++--------- feedgen/item.py | 3 +- feedgen/tests/test_feed.py | 56 ++++++++++++++++++++----- 3 files changed, 112 insertions(+), 31 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 0e59ddd..d1138a6 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -14,7 +14,7 @@ import dateutil.parser import dateutil.tz from feedgen.item import BaseEpisode -from feedgen.util import ensure_format, formatRFC2822 +from feedgen.util import ensure_format, formatRFC2822, listToHumanreadableStr from feedgen.person import Person import feedgen.version import sys @@ -241,16 +241,29 @@ def _create_rss(self): lastBuildDate.text = formatRFC2822(lastBuildDateDate) if self.__rss_author: - if self.__rss_author.email: - managingEditor = etree.SubElement(channel, 'managingEditor') - managingEditor.text = str(self.__rss_author) + authors_with_name = [a.name for a in self.__rss_author if a.name] + if authors_with_name: + # We have something to display as itunes:author, combine all + # names + itunes_author = \ + etree.SubElement(channel, '{%s}author' % ITUNES_NS) + itunes_author.text = listToHumanreadableStr(authors_with_name) + if len(self.__rss_author) > 1 or not self.__rss_author[0].email: + # Use dc:creator, since it supports multiple authors (and + # author without email) + for a in self.__rss_author or []: + author = etree.SubElement(channel, + '{%s}creator' % nsmap['dc']) + if a.name and a.email: + author.text = "%s <%s>" % (a.name, a.email) + elif a.name: + author.text = a.name + else: + author.text = a.email else: - creator = etree.SubElement(channel, '{%s}creator' % nsmap['dc']) - creator.text = str(self.__rss_author) - if self.__rss_author.name: - itunes_author = etree.SubElement(channel, - '{%s}author' % ITUNES_NS) - itunes_author.text = self.__rss_author.name + # Only one author and with email, so use rss managingEditor + author = etree.SubElement(channel, 'managingEditor') + author.text = str(self.__rss_author[0]) if self.__rss_pubDate is None: episode_dates = [e.published() for e in self.episodes if e.published() is not None] @@ -556,20 +569,51 @@ def language(self, language=None): return self.__rss_language - def author(self, author=None): - """Set or get which person or entity is responsible for the podcast's - editorial content. + def author(self, *author, replace=False): + """Append or get which person(s) or entity/entities is/are responsible + for the podcast's editorial content. - This person's name, if supplied, is shown on iTunes under the podcast's - title. Otherwise, the email is used. + When called multiple times, the authors are appended to the list, unless + you set replace to ``True``. - :param author: The person or entity responsible for editorial - content. + The names supplied are shown on iTunes under the podcast's title. + + One or more :class:`~feedgen.person.Person` objects can be passed to + this method. + + .. note:: + + Remember to unpack any lists you use, since lists are not allowed as + parameters. + + Example:: + + >>> my_authors = [Person("John Doe"), Person("Mary Sue")] + >>> p.author(*my_authors) + >>> # Or don't use a list to begin with + >>> p.author(Person("John Doe"), Person("Mary Sue"), replace=True) + + :param author: One or multiple persons or entities who are + responsible for the editorial content. :type author: feedgen.person.Person - :returns: The person or entity responsible for editorial content. + :param replace: Set to ``True`` to start the list of authors from + scratch again, thus replacing any authors already on the list. + :type replace: bool + :returns: List of :class:`~feedgen.person.Person` responsible for + editorial content. """ - if author is not None: - self.__rss_author = author + # TODO: Rename author to authors + if not author is None: + # Check that the authors quack like ducks + for a in author: + if not (hasattr(a, "name") and hasattr(a, "email")): + raise TypeError("Author parameter %s does not have the " + "attributes name and/or email. You " + "didn't forget to unpack a list?" % a) + + if replace or self.__rss_author is None: + self.__rss_author = [] + self.__rss_author.extend(author) return self.__rss_author diff --git a/feedgen/item.py b/feedgen/item.py index f9c2be5..2c7ab68 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -215,11 +215,12 @@ def author(self, *authors, replace=False): :param authors: One or more Person objects that will be added to the list of authors for this episode. Lists are not accepted, they must be unpacked first (see example). - :type authors: list or Person + :type authors: Person :param replace: Set to True to start over from an empty list. :type replace: bool :returns: The current list of authors. """ + # TODO: Rename author to authors if not authors is None: # Check that the authors quack like ducks for a in authors: diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 9034961..f0dcf88 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -75,7 +75,7 @@ def test_baseFeed(self): assert fg.name() == self.title - assert fg.author() == self.author + assert fg.author()[0] == self.author assert fg.webMaster() == self.webMaster assert fg.website() == self.linkHref @@ -178,10 +178,10 @@ def getLastBuildDateElement(fg): def test_AuthorEmail(self): # Just email - so use managingEditor, not dc:creator or itunes:author # This is per the RSS best practices, see the section about dc:creator - self.fg.author(Person(None, "justan@email.address")) + self.fg.author(Person(None, "justan@email.address"), replace=True) channel = self.fg._create_rss().find("channel") # managingEditor uses email? - assert channel.find("managingEditor").text == self.fg.author().email + assert channel.find("managingEditor").text == self.fg.author()[0].email # No dc:creator? assert channel.find("{%s}creator" % self.nsDc) is None # No itunes:author? @@ -189,31 +189,67 @@ def test_AuthorEmail(self): def test_AuthorName(self): # Just name - use dc:creator and itunes:author, not managingEditor - self.fg.author(Person("Just a. Name")) + self.fg.author(Person("Just a. Name"), replace=True) channel = self.fg._create_rss().find("channel") # No managingEditor? assert channel.find("managingEditor") is None # dc:creator equals name? assert channel.find("{%s}creator" % self.nsDc).text == \ - self.fg.author().name + self.fg.author()[0].name # itunes:author equals name? assert channel.find("{%s}author" % self.nsItunes).text == \ - self.fg.author().name + self.fg.author()[0].name def test_AuthorNameAndEmail(self): # Both name and email - use managingEditor and itunes:author, # not dc:creator - self.fg.author(Person("Both a name", "and_an@email.com")) + self.fg.author(Person("Both a name", "and_an@email.com"), replace=True) channel = self.fg._create_rss().find("channel") # Does managingEditor follow the pattern "email (name)"? - self.assertEqual(self.fg.author().email + - " (" + self.fg.author().name + ")", + self.assertEqual(self.fg.author()[0].email + + " (" + self.fg.author()[0].name + ")", channel.find("managingEditor").text) # No dc:creator? assert channel.find("{%s}creator" % self.nsDc) is None # itunes:author uses name only? assert channel.find("{%s}author" % self.nsItunes).text == \ - self.fg.author().name + self.fg.author()[0].name + + def test_multipleAuthors(self): + # Multiple authors - use itunes:author and dc:creator, not + # managingEditor. + # Is an exception raised when a list is passed in? + self.assertRaises(TypeError, self.fg.author, + [Person("A List", "is@not.allowed")]) + + person1 = Person("Multiple", "authors@example.org") + person2 = Person("Are", "cool@example.org") + self.fg.author(person1, person2, replace=True) + channel = self.fg._create_rss().find("channel") + + # Test dc:creator + author_elements = \ + channel.findall("{%s}creator" % self.nsDc) + author_texts = [e.text for e in author_elements] + + assert len(author_texts) == 2 + assert person1.name in author_texts[0] + assert person1.email in author_texts[0] + assert person2.name in author_texts[1] + assert person2.email in author_texts[1] + + # Test itunes:author + itunes_author = channel.find("{%s}author" % self.nsItunes) + assert itunes_author is not None + itunes_author_text = itunes_author.text + assert person1.name in itunes_author_text + assert person1.email not in itunes_author_text + assert person2.name in itunes_author_text + assert person2.email not in itunes_author_text + + # Test that managingEditor is not used + assert channel.find("managingEditor") is None + def test_webMaster(self): self.fg.webMaster(Person(None, "justan@email.address")) From 5886ea1526feb00dcf26610ea1c38cca496ab65c Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 1 Jul 2016 13:22:04 +0200 Subject: [PATCH 056/200] Refactor author into attribute authors Just like Podcast.episodes, the list of authors is now exposed so that users can use this familiar concept to add authors. Pretty major API changes. * author is now called authors, both in Podcast and BaseEpisode. * authors is now a property (attribute), not a method. * You can assign new lists to authors, where you earlier would set replace=True. * You can also append new Person to authors, or remove existing authors (which you couldn't do before, without removing _all_ authors). --- doc/user/basic_usage_guide/part_1.rst | 4 +- doc/user/basic_usage_guide/part_2.rst | 18 +++--- feedgen/__main__.py | 4 +- feedgen/feed.py | 82 ++++++++++++------------- feedgen/item.py | 86 ++++++++++++++------------- feedgen/person.py | 23 ++++--- feedgen/tests/test_entry.py | 22 ++++--- feedgen/tests/test_feed.py | 33 +++++----- 8 files changed, 141 insertions(+), 131 deletions(-) diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index a59f01e..a12ad89 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -56,7 +56,7 @@ Commonly used p.copyright = "© 2016 Example Radio" p.language = "en-US" - p.author = p.Person("John Doe", "editor@example.org") + p.authors = [p.Person("John Doe", "editor@example.org")] p.feed_url = "https://example.com/feeds/podcast.rss" p.category = Category("Technology", "Podcasting") p.explicit = True @@ -66,7 +66,7 @@ Commonly used ~feedgen.feed.Podcast.copyright ~feedgen.feed.Podcast.language - ~feedgen.feed.Podcast.author + ~feedgen.feed.Podcast.authors ~feedgen.feed.Podcast.feed_url ~feedgen.feed.Podcast.category ~feedgen.feed.Podcast.explicit diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index a48cdbc..fccd0c3 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -145,8 +145,8 @@ the link. :: .. autosummary:: ~feedgen.item.BaseEpisode.link -The Author -^^^^^^^^^^ +The Authors +^^^^^^^^^^^ .. note:: @@ -155,21 +155,21 @@ The Author attributes at the episode level if they **differ** from their value at the podcast level. -Normally, the attributes :attr:`Podcast.author ` +Normally, the attributes :attr:`Podcast.authors ` and :attr:`Podcast.webMaster ` (if set) are -used to determine the author of an episode. Thus, if all your episodes have -the same author, you should just set it at the podcast level. +used to determine the authors of an episode. Thus, if all your episodes have +the same authors, you should just set it at the podcast level. -If an episode's author differs from the podcast's, though, you can override it +If an episode's authors differs from the podcast's, though, you can override it like this:: - my_episode.author = Person("Joe Bob") + my_episode.authors = [Person("Joe Bob")] You can even have multiple authors:: - my_episode.author = [Person("Joe Bob"), Person("Alice Bob")] + my_episode.authors = [Person("Joe Bob"), Person("Alice Bob")] -.. autosummary:: ~feedgen.item.BaseEpisode.author +.. autosummary:: ~feedgen.item.BaseEpisode.authors Category diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 7806fb3..3d7d4d6 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -47,7 +47,7 @@ def main(): # Initialize the feed p = Podcast() p.name('Testfeed') - p.author(Person("Lars Kiesow", "lkiesow@uos.de")) + p.authors.append(Person("Lars Kiesow", "lkiesow@uos.de")) p.website(href='http://example.com') p.copyright('cc-by') p.description('This is a cool feed!') @@ -69,7 +69,7 @@ def main(): occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, verba <3.''', html=False) e1.link(href='http://example.com') - e1.author(Person('Lars Kiesow', 'lkiesow@uos.de')) + e1.authors = [Person('Lars Kiesow', 'lkiesow@uos.de')] e1.published(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) # Should we just print out, or write to file? diff --git a/feedgen/feed.py b/feedgen/feed.py index d1138a6..a80eb5d 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -51,7 +51,7 @@ def __init__(self): self.__rss_generator = self._feedgen_generator_str self.__rss_language = None self.__rss_lastBuildDate = None - self.__rss_author = None + self.__rss_authors = [] self.__rss_pubDate = None self.__rss_skipHours = None self.__rss_skipDays = None @@ -240,18 +240,18 @@ def _create_rss(self): lastBuildDate = etree.SubElement(channel, 'lastBuildDate') lastBuildDate.text = formatRFC2822(lastBuildDateDate) - if self.__rss_author: - authors_with_name = [a.name for a in self.__rss_author if a.name] + if self.__rss_authors: + authors_with_name = [a.name for a in self.__rss_authors if a.name] if authors_with_name: # We have something to display as itunes:author, combine all # names itunes_author = \ etree.SubElement(channel, '{%s}author' % ITUNES_NS) itunes_author.text = listToHumanreadableStr(authors_with_name) - if len(self.__rss_author) > 1 or not self.__rss_author[0].email: + if len(self.__rss_authors) > 1 or not self.__rss_authors[0].email: # Use dc:creator, since it supports multiple authors (and # author without email) - for a in self.__rss_author or []: + for a in self.__rss_authors or []: author = etree.SubElement(channel, '{%s}creator' % nsmap['dc']) if a.name and a.email: @@ -263,7 +263,7 @@ def _create_rss(self): else: # Only one author and with email, so use rss managingEditor author = etree.SubElement(channel, 'managingEditor') - author.text = str(self.__rss_author[0]) + author.text = str(self.__rss_authors[0]) if self.__rss_pubDate is None: episode_dates = [e.published() for e in self.episodes if e.published() is not None] @@ -569,52 +569,46 @@ def language(self, language=None): return self.__rss_language - def author(self, *author, replace=False): - """Append or get which person(s) or entity/entities is/are responsible - for the podcast's editorial content. - - When called multiple times, the authors are appended to the list, unless - you set replace to ``True``. + @property + def authors(self): + """List of :class:`~feedgen.person.Person` that are responsible for this + podcast's editorial content. - The names supplied are shown on iTunes under the podcast's title. + Any value you assign to authors will be automatically converted to a + list, but only if it's iterable (like tuple, set and so on). It is an + error to assign a single :class:`~feedgen.person.Person` object to this + attribute:: - One or more :class:`~feedgen.person.Person` objects can be passed to - this method. + >>> # This results in an error + >>> p.authors = Person("John Doe", "johndoe@example.org") + TypeError: Only iterable types can be assigned to authors, ... + >>> # This is the correct way: + >>> p.authors = [Person("John Doe", "johndoe@example.org")] - .. note:: + The authors don't need to have both name and email set. The names are + shown under the podcast's title on iTunes. - Remember to unpack any lists you use, since lists are not allowed as - parameters. + The initial value is an empty list, so you can use the list methods + right away. Example:: - >>> my_authors = [Person("John Doe"), Person("Mary Sue")] - >>> p.author(*my_authors) - >>> # Or don't use a list to begin with - >>> p.author(Person("John Doe"), Person("Mary Sue"), replace=True) - - :param author: One or multiple persons or entities who are - responsible for the editorial content. - :type author: feedgen.person.Person - :param replace: Set to ``True`` to start the list of authors from - scratch again, thus replacing any authors already on the list. - :type replace: bool - :returns: List of :class:`~feedgen.person.Person` responsible for - editorial content. + >>> # This attribute is just a list - you can for example append: + >>> p.authors.append(Person("John Doe", "johndoe@example.org")) + >>> # Or they can be given as new list (overriding earlier authors) + >>> p.authors = [Person("John Doe", "johndoe@example.org"), + ... Person("Mary Sue", "marysue@example.org")] """ - # TODO: Rename author to authors - if not author is None: - # Check that the authors quack like ducks - for a in author: - if not (hasattr(a, "name") and hasattr(a, "email")): - raise TypeError("Author parameter %s does not have the " - "attributes name and/or email. You " - "didn't forget to unpack a list?" % a) - - if replace or self.__rss_author is None: - self.__rss_author = [] - self.__rss_author.extend(author) - return self.__rss_author + return self.__rss_authors + + @authors.setter + def authors(self, authors): + try: + self.__rss_authors = list(authors) + except TypeError: + raise TypeError("Only iterable types can be assigned to authors, " + "%s given. You must put your object in a list, " + "even if there's only one author." % authors) def published(self, pubDate=None): diff --git a/feedgen/item.py b/feedgen/item.py index 2c7ab68..80eb3a7 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -29,7 +29,7 @@ class BaseEpisode(object): def __init__(self): # RSS - self.__rss_author = None + self.__rss_authors = [] self.__rss_content = None self.__rss_enclosure = None self.__rss_guid = None @@ -74,18 +74,18 @@ def rss_entry(self, extensions=True): 'http://purl.org/rss/1.0/modules/content/') content.text = etree.CDATA(self.__rss_content) - if self.__rss_author: - authors_with_name = [a.name for a in self.__rss_author if a.name] + if self.__rss_authors: + authors_with_name = [a.name for a in self.__rss_authors if a.name] if authors_with_name: # We have something to display as itunes:author, combine all # names itunes_author = \ etree.SubElement(entry, '{%s}author' % ITUNES_NS) itunes_author.text = listToHumanreadableStr(authors_with_name) - if len(self.__rss_author) > 1 or not self.__rss_author[0].email: + if len(self.__rss_authors) > 1 or not self.__rss_authors[0].email: # Use dc:creator, since it supports multiple authors (and # author without email) - for a in self.__rss_author or []: + for a in self.__rss_authors or []: author = etree.SubElement(entry, '{%s}creator' % DUBLIN_NS) if a.name and a.email: author.text = "%s <%s>" % (a.name, a.email) @@ -96,7 +96,7 @@ def rss_entry(self, extensions=True): else: # Only one author and with email, so use rss author author = etree.SubElement(entry, 'author') - author.text = str(self.__rss_author[0]) + author.text = str(self.__rss_authors[0]) if self.__rss_guid: rss_guid = self.__rss_guid @@ -195,45 +195,51 @@ def id(self, new_id=None): return self.__rss_guid - def author(self, *authors, replace=False): - """Get or append to the list of Person that contributed to this episode. + @property + def authors(self): + """List of :class:`~feedgen.person.Person` that contributed to this + episode. - The authors don't need to have both name and email set. + The authors don't need to have both name and email set. The names are + shown under the podcast's title on iTunes. + + .. note:: + + You do not need to provide any authors for an episode if + they're identical to the podcast's authors. + + Any value you assign to authors will be automatically converted to a + list, but only if it's iterable (like tuple, set and so on). It is an + error to assign a single :class:`~feedgen.person.Person` object to this + attribute:: + + >>> # This results in an error + >>> ep.authors = Person("John Doe", "johndoe@example.org") + TypeError: Only iterable types can be assigned to authors, ... + >>> # This is the correct way: + >>> ep.authors = [Person("John Doe", "johndoe@example.org")] + + The initial value is an empty list, so you can use the list methods + right away. Example:: - >>> ep.author(Person("John Doe", "johndoe@example.org")) - - >>> # Multiple authors can be given as separate parameters - >>> ep.author(Person("John Doe", "johndoe@example.org"), - ... Person("Mary Sue", "marysue@example.org"), replace=True) - >>> # Or as one unpacked list (just passing a list is an error) - >>> ep.author(*[Person("John Doe", "johndoe@example.org"), - ... Person("Mary Sue", "marysue@example.org")], - ... replace=True) - - :param authors: One or more Person objects that will be added to the - list of authors for this episode. Lists are not accepted, they - must be unpacked first (see example). - :type authors: Person - :param replace: Set to True to start over from an empty list. - :type replace: bool - :returns: The current list of authors. + >>> # This attribute is just a list - you can for example append: + >>> ep.authors.append(Person("John Doe", "johndoe@example.org")) + >>> # Or assign a new list (discarding earlier authors) + >>> ep.authors = [Person("John Doe", "johndoe@example.org"), + ... Person("Mary Sue", "marysue@example.org")] """ - # TODO: Rename author to authors - if not authors is None: - # Check that the authors quack like ducks - for a in authors: - if not (hasattr(a, "name") and hasattr(a, "email")): - raise TypeError("Author parameter %s does not have the " - "attributes name and/or email. You " - "didn't forget to unpack a list?" % a) - - if replace or self.__rss_author is None: - self.__rss_author = [] - self.__rss_author.extend(authors) - return self.__rss_author - + return self.__rss_authors + + @authors.setter + def authors(self, authors): + try: + self.__rss_authors = list(authors) + except TypeError: + raise TypeError("Only iterable types can be assigned to authors, " + "%s given. You must put your object in a list, " + "even if there's only one author." % authors) def summary(self, new_summary=None, html=True): """Get or set the summary of this episode. diff --git a/feedgen/person.py b/feedgen/person.py index 885f4d5..a339533 100644 --- a/feedgen/person.py +++ b/feedgen/person.py @@ -1,12 +1,26 @@ class Person(object): - """Class representing a single person or entity. + """Data-oriented class representing a single person or entity. + + A Person can represent both real persons and organizations, entities + and so on. Example:: + + >>> p.authors = [Person("Example Radio", "mail@example.org")] .. note:: At any time, one of name or email must be present. Both cannot be None or empty at the same time. - Example:: + .. warning:: + + **Any names and email addresses** you put into a Person object, will + eventually be included in the feed and thus **published** together with + the feed. If you want to keep a name or email address private, then you + must make sure it isn't used in a Person object (or to be precise: that + the Person object with the name or email address isn't used in any + Podcast or Episode.) + + Example of use:: >>> from feedgen.person import Person >>> Person("John Doe") @@ -21,11 +35,6 @@ class Person(object): def __init__(self, name=None, email=None): """Create new person with a name, email or both. - A Person can represent both real persons and organizations, entities - and so on. Example:: - - >>> p.managingEditor = Person("Example Radio", "mail@example.org") - You don't need to provide both a name and an email, but you must provide one of them. diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index b9198cc..9ed1510 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -48,11 +48,6 @@ def setUp(self): self.fg = fg - def test_checkEntryNumbers(self): - - fg = self.fg - assert len(fg.episodes) == 3 - def test_checkItemNumbers(self): fg = self.fg @@ -158,7 +153,7 @@ def test_feedPubDateDisabled(self): def test_oneAuthor(self): name = "John Doe" email = "johndoe@example.org" - self.fe.author(Person(name, email)) + self.fe.authors = [Person(name, email)] author_text = self.fe.rss_entry().find("author").text assert name in author_text assert email in author_text @@ -171,7 +166,7 @@ def test_oneAuthor(self): def test_oneAuthorWithoutEmail(self): name = "John Doe" - self.fe.author(Person(name)) + self.fe.authors.append(Person(name)) entry = self.fe.rss_entry() # Test that author is not used, since it requires email @@ -184,7 +179,7 @@ def test_oneAuthorWithoutEmail(self): def test_oneAuthorWithoutName(self): email = "johndoe@example.org" - self.fe.author(Person(email=email)) + self.fe.authors.extend([Person(email=email)]) entry = self.fe.rss_entry() # Test that rss author is the email @@ -199,10 +194,7 @@ def test_multipleAuthors(self): person1 = Person("John Doe", "johndoe@example.org") person2 = Person("Mary Sue", "marysue@example.org") - # Check that an error occurs if a list is given - self.assertRaises(TypeError, self.fe.author, [person1, person2]) - # Do it properly this time - self.fe.author(*[person1, person2]) + self.fe.authors = [person1, person2] author_elements = \ self.fe.rss_entry().findall("{%s}creator" % self.dublin_ns) author_texts = [e.text for e in author_elements] @@ -223,3 +215,9 @@ def test_multipleAuthors(self): # Test that the regular rss tag is not used, per the RSS recommendations assert self.fe.rss_entry().find("author") is None + + def test_authorsInvalidAssignment(self): + self.assertRaises(TypeError, self.do_authorsInvalidAssignment) + + def do_authorsInvalidAssignment(self): + self.fe.authors = Person("Oh No", "notan@iterable.no") diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index f0dcf88..33be12e 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -61,7 +61,7 @@ def setUp(self): path=self.cloudPath, registerProcedure=self.cloudRegisterProcedure, protocol=self.cloudProtocol) fg.copyright(self.copyright) - fg.author(self.author) + fg.authors.append(self.author) fg.skipDays(self.skipDays) fg.skipHours(self.skipHours) fg.webMaster(self.webMaster) @@ -75,7 +75,7 @@ def test_baseFeed(self): assert fg.name() == self.title - assert fg.author()[0] == self.author + assert fg.authors[0] == self.author assert fg.webMaster() == self.webMaster assert fg.website() == self.linkHref @@ -178,10 +178,10 @@ def getLastBuildDateElement(fg): def test_AuthorEmail(self): # Just email - so use managingEditor, not dc:creator or itunes:author # This is per the RSS best practices, see the section about dc:creator - self.fg.author(Person(None, "justan@email.address"), replace=True) + self.fg.authors = [Person(None, "justan@email.address")] channel = self.fg._create_rss().find("channel") # managingEditor uses email? - assert channel.find("managingEditor").text == self.fg.author()[0].email + assert channel.find("managingEditor").text == self.fg.authors[0].email # No dc:creator? assert channel.find("{%s}creator" % self.nsDc) is None # No itunes:author? @@ -189,42 +189,39 @@ def test_AuthorEmail(self): def test_AuthorName(self): # Just name - use dc:creator and itunes:author, not managingEditor - self.fg.author(Person("Just a. Name"), replace=True) + self.fg.authors = [Person("Just a. Name")] channel = self.fg._create_rss().find("channel") # No managingEditor? assert channel.find("managingEditor") is None # dc:creator equals name? assert channel.find("{%s}creator" % self.nsDc).text == \ - self.fg.author()[0].name + self.fg.authors[0].name # itunes:author equals name? assert channel.find("{%s}author" % self.nsItunes).text == \ - self.fg.author()[0].name + self.fg.authors[0].name def test_AuthorNameAndEmail(self): # Both name and email - use managingEditor and itunes:author, # not dc:creator - self.fg.author(Person("Both a name", "and_an@email.com"), replace=True) + self.fg.authors = [Person("Both a name", "and_an@email.com")] channel = self.fg._create_rss().find("channel") # Does managingEditor follow the pattern "email (name)"? - self.assertEqual(self.fg.author()[0].email + - " (" + self.fg.author()[0].name + ")", + self.assertEqual(self.fg.authors[0].email + + " (" + self.fg.authors[0].name + ")", channel.find("managingEditor").text) # No dc:creator? assert channel.find("{%s}creator" % self.nsDc) is None # itunes:author uses name only? assert channel.find("{%s}author" % self.nsItunes).text == \ - self.fg.author()[0].name + self.fg.authors[0].name def test_multipleAuthors(self): # Multiple authors - use itunes:author and dc:creator, not # managingEditor. - # Is an exception raised when a list is passed in? - self.assertRaises(TypeError, self.fg.author, - [Person("A List", "is@not.allowed")]) person1 = Person("Multiple", "authors@example.org") person2 = Person("Are", "cool@example.org") - self.fg.author(person1, person2, replace=True) + self.fg.authors = [person1, person2] channel = self.fg._create_rss().find("channel") # Test dc:creator @@ -250,6 +247,12 @@ def test_multipleAuthors(self): # Test that managingEditor is not used assert channel.find("managingEditor") is None + def test_authorsInvalidValue(self): + self.assertRaises(TypeError, self.do_authorsInvalidValue) + + def do_authorsInvalidValue(self): + self.fg.authors = Person("Opsie", "forgot@list.org") + def test_webMaster(self): self.fg.webMaster(Person(None, "justan@email.address")) From 46b1d0d0cc9c4781be10cb033a66e2a6f0c6b5f0 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 02:40:38 +0200 Subject: [PATCH 057/200] Create the Media class --- .travis.yml | 3 +- Makefile | 1 + doc/api.media.rst | 5 + doc/api.rst | 4 +- feedgen/media.py | 208 +++++++++++++++++++++ feedgen/not_supported_by_itunes_warning.py | 2 + feedgen/tests/test_media.py | 119 ++++++++++++ requirements.txt | 1 + 8 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 doc/api.media.rst create mode 100644 feedgen/media.py create mode 100644 feedgen/not_supported_by_itunes_warning.py create mode 100644 feedgen/tests/test_media.py diff --git a/.travis.yml b/.travis.yml index 219a9a2..295bc63 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,9 @@ language: python python: - "2.6" - "2.7" - - "3.2" - "3.3" - "3.4" -before_install: pip install --quiet lxml python-dateutil +before_install: pip install --quiet -r requirements.txt script: make test diff --git a/Makefile b/Makefile index a8b3383..5c38c51 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,7 @@ test: -python -m unittest feedgen.tests.test_feed -python -m unittest feedgen.tests.test_entry -python -m unittest feedgen.tests.test_person + -python -m unittest feedgen.tests.test_media -python -m unittest feedgen.tests.test_util -python -m feedgen rss > /dev/null @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml diff --git a/doc/api.media.rst b/doc/api.media.rst new file mode 100644 index 0000000..593a7ce --- /dev/null +++ b/doc/api.media.rst @@ -0,0 +1,5 @@ +feedgen.media.Media +=================== + +.. autoclass:: feedgen.media.Media + :members: diff --git a/doc/api.rst b/doc/api.rst index b778240..a9d4815 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -32,7 +32,8 @@ When you create episodes for a Podcast, you're most likely creating new instances of :class:`feedgen.item.BaseEpisode`. You use :class:`feedgen.person.Person` whenever an attribute is to represent -a person or an entity. +a person or an entity, and :class:`feedgen.media.Media` with the +:attr:`feedgen.item.BaseEpisode.enclosure` attribute. :mod:`feedgen.util` provides utility functions for the rest of the library, and is therefore not relevant for users. @@ -45,4 +46,5 @@ and is therefore not relevant for users. api.feed api.item api.person + api.media api.util diff --git a/feedgen/media.py b/feedgen/media.py new file mode 100644 index 0000000..be84326 --- /dev/null +++ b/feedgen/media.py @@ -0,0 +1,208 @@ +import warnings +from future.moves.urllib.parse import urlparse + +from feedgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning + + +class Media(object): + """ + Data-oriented class representing a pointer to a media file. + + A media file can be a sound file (most typical), video file or a document. + + You should provide the url at which this media can be found, and the media's + file size in bytes. Optionally, you can provide the type of media + (expressed in the media type format). When not given in the constructor, it + will be found automatically by looking at the url's file extension. If the + url's file extension isn't supported by iTunes, you will get an error if you + don't supply the type. + + .. note:: + + iTunes is lazy and will just look at the URL to figure out if + a file is of a supported file type. You must therefore ensure your URL + ends with a supported file extension. + + .. note:: + + A warning called :class:`~feedgen.not_supported_by_itunes_warning.NotSupportedByItunesWarning` + will be issued if your URL or type isn't compatible with iTunes. See + the Python documentation for more details on :mod:`warnings`. + + Media types supported by iTunes: + + * Audio + * M4A + * MP3 + * Video + * MOV + * MP4 + * M4V + * Document + * PDF + * EPUB + + + """ + file_types = { + 'm4a': 'audio/x-m4a', + 'mp3': 'audio/mpeg', + 'mov': 'video/quicktime', + 'mp4': 'video/mp4', + 'm4v': 'video/x-m4v', + 'pdf': 'application/pdf', + 'epub': 'document/x-epub', + } + + def __init__(self, url, size=0, type=None): + self._url = None + self._size = None + self._type = None + + self.url = url + self.size = size + self.type = type or self.get_type(url) + + @property + def url(self): + """The URL at which this media is publicly accessible. + + Only absolute URLs are allowed, so make sure it starts with http:// or + https://. The server should support HEAD-requests in addition to + multi-range requests.""" + return self._url + + @url.setter + def url(self, url): + if not url: + raise ValueError("url cannot be empty or None") + parsed_url = urlparse(url) + file_extension = parsed_url.path.split('.')[-1].lower() + if file_extension not in self.file_types: + warnings.warn("File extension %s is not supported by iTunes." + % file_extension, NotSupportedByItunesWarning) + if parsed_url.scheme not in ("http", "https"): + warnings.warn("URL scheme %s is not supported by iTunes. Make sure " + "you use absolute URLs and HTTP or HTTPS." + % parsed_url.scheme, NotSupportedByItunesWarning) + self._url = url + + @property + def size(self): + """The media's file size in bytes. + + You can either provide the number of bytes as an int, or you can + provide a human-readable str with a unit, like MB or GiB. + + An unknown size is represented as 0. You should strive to provide a + size, so your listeners aren't surprised by any big files. + + .. note:: + + If you provide a string, it will be translated to int when the + assignment happens. Thus, on subsequent access, you will get the + resulting int, not the string you put in. + + .. note:: + + The units are case-insensitive. This means that the ``B`` is + always assumed to mean "bytes", even if it is lowercase (``b``). + Likewise, ``m`` is taken to mean mega, not milli. + """ + return self._size + + @size.setter + def size(self, size): + try: + size = int(size) + if size < 0: + raise ValueError("File size must be 0 if unknown, or a positive" + " integer.") + self._size = size + except ValueError: + self.size = self._str_to_bytes(size) + except TypeError as e: + if size is None: + self.size = 0 + else: + raise e + + @staticmethod + def _str_to_bytes(size): + units = { + "b": 1, + "kb": 1000, + "kib": 1024, + "mb": 1000**2, + "mib": 1024**2, + "gb": 1000**3, + "gib": 1024**3, + "tb": 1000**4, + "tib": 1024**4 + } + size = str(size).lower().strip().replace(" ", "") + number = float(size.rstrip("bkimgt")) + unit = size.lstrip("0123456789.") + try: + return round(number * units[unit]) + except KeyError: + raise ValueError("The unit %s was not recognized." % unit) + + @property + def type(self): + """The media type of this media. + + See https://en.wikipedia.org/wiki/Media_type for an introduction. + + .. note:: + + If you leave out type when creating a new Media object, the + type will be auto-detected from the :attr:`~feedgen.media.Media.url` + attribute. However, this won't happen automatically other than + during initialization. If you want to autodetect type when + assigning a new value to url, you should use + :meth:`~feedgen.media.Media.get_type`. + """ + return self._type + + @type.setter + def type(self, type): + type = type.strip().lower() + + if not type in self.file_types.values(): + warnings.warn("Media type %s is not supported by iTunes.", + NotSupportedByItunesWarning) + self._type = type + + def get_type(self, url): + """Autodetect the media type, given the url. + + Example:: + + >>> from feedgen.media import Media + >>> m = Media("http://example.org/1.mp3", 136532744) + >>> # The type was detected from the url: + >>> m.type + audio/mpeg + >>> # Ops, I changed my mind... + >>> m.url = "https://example.org/1.m4a" + >>> # As you can see, the type didn't change: + >>> m.type + audio/mpeg + >>> # So update type yourself + >>> m.type = m.get_type(m.url) + >>> m.type + audio/x-m4a + """ + file_extension = urlparse(url).path.split(".")[-1] + try: + return self.file_types[file_extension] + except KeyError as e: + raise ValueError("The file extension %s was not recognized, which " + "means it's not supported by iTunes. If this is " + "intended, please provide the type yourself so " + "clients can see what type of file it is." + % file_extension) from e + + + diff --git a/feedgen/not_supported_by_itunes_warning.py b/feedgen/not_supported_by_itunes_warning.py new file mode 100644 index 0000000..7c5f8ad --- /dev/null +++ b/feedgen/not_supported_by_itunes_warning.py @@ -0,0 +1,2 @@ +class NotSupportedByItunesWarning(UserWarning): + pass diff --git a/feedgen/tests/test_media.py b/feedgen/tests/test_media.py new file mode 100644 index 0000000..afe6087 --- /dev/null +++ b/feedgen/tests/test_media.py @@ -0,0 +1,119 @@ +from future.utils import iteritems +import unittest +import warnings + +from feedgen.media import Media +from feedgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning + +class TestSequenceFunctions(unittest.TestCase): + def setUp(self): + self.url = "http://example.com/2016/5/17/The+awesome+episode.mp3" + self.size = 144253424 + self.type = "audio/mpeg" + self.expected_type = ("audio/mpeg3", "audio/x-mpeg-3", "audio/mpeg") + warnings.simplefilter("ignore") + + def test_constructorOneArgument(self): + m = Media(self.url) + assert m.url == self.url + assert m.size == 0 + assert m.type in self.expected_type + + def test_constructorTwoArguments(self): + m = Media(self.url, self.size) + assert m.url == self.url + assert m.size == self.size + assert m.type in self.expected_type + + def test_constructorThreeArguments(self): + m = Media(self.url, self.size, self.type) + assert m.url == self.url + assert m.size == self.size + assert m.type == self.type + + def test_assigningUrl(self): + m = Media(self.url) + another_url = "http://example.com/2016/5/17/The+awful+episode.mp3" + m.url = another_url + assert m.url == another_url + + def test_assigningSize(self): + m = Media(self.url, self.size) + another_size = 1234567 + m.size = another_size + assert m.size == another_size + + def test_assigningType(self): + m = Media(self.url, self.size, self.type) + another_type = "audio/x-mpeg-3" + m.type = another_type + assert m.type == another_type + + def test_autoRecognizeType(self): + url = "http://example.com/2016/5/17/The+converted+episode%s" + + # Mapping between url file extension and type given by iTunes + # https://help.apple.com/itc/podcasts_connect/#/itcb54353390 + types = { + '.mp3': {"audio/mpeg"}, + '.m4a': {"audio/x-m4a"}, + '.mov': {"video/quicktime"}, + '.mp4': {"video/mp4"}, + '.m4v': {"video/x-m4v"}, + '.pdf': {"application/pdf"}, + '.epub': {"document/x-epub"}, + } + + for (file_extension, allowed_types) in iteritems(types): + m = Media(url % file_extension) + assert m.type in allowed_types, "%s gave type %s, expected %s" % \ + (file_extension, m.type, + allowed_types) + + def test_invalidFileExtension(self): + self.assertRaises(ValueError, Media, "http://episode.example.org/1") + self.assertRaises(ValueError, Media, "http://ep.example.org/.mp3/1.mp2") + + def test_anyExtensionAllowedWithType(self): + m = Media("http://episode.example.org/yo.ogg", + 3453245, + "audio/ogg") + + def test_warningGivenIfNotSupportedByItunes(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.assertEqual(len(w), 0) + # Use a type not supported by itunes + self.test_anyExtensionAllowedWithType() + # Check that two warnings were issued + self.assertEqual(len(w), 2) + # Warning: file extension not recognized + assert issubclass(w[0].category, NotSupportedByItunesWarning), \ + str(w[0]) + # Warning: type not recognized + assert issubclass(w[1].category, NotSupportedByItunesWarning) + + # Use url extension supported by itunes, but a type not supported + m = Media(self.url, self.size, "audio/mpeg3") + # Check that a new warning was issued + assert len(w) == 3 + # Warning: type not recognized + assert issubclass(w[2].category, NotSupportedByItunesWarning) + + def test_strToSize(self): + sizes = { + "12 kB": 12000, + "12 kib": 12288, + "15MB": 15000000, + "15MiB": 15728640, + "0.32GB": 320000000, + "0.32GiB": 343597384, + "462GB": 462000000000, + "4TB": 4000000000000, + "4 TiB": 4398046511104, + "1 000 KB": 1000000, + "145B": 145, + } + + for (str_size, expected_size) in iteritems(sizes): + self.assertEqual(expected_size, Media._str_to_bytes(str_size)) diff --git a/requirements.txt b/requirements.txt index 31df655..e92b9b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ dateutils lxml pytz +future From 0a656c5e351a1be2b9565ffebcf576012e07133a Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 11:04:55 +0200 Subject: [PATCH 058/200] Add method which lets you fetch Media data from server --- feedgen/media.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/feedgen/media.py b/feedgen/media.py index be84326..e33dd15 100644 --- a/feedgen/media.py +++ b/feedgen/media.py @@ -2,6 +2,7 @@ from future.moves.urllib.parse import urlparse from feedgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from feedgen import version class Media(object): @@ -204,5 +205,49 @@ def get_type(self, url): "clients can see what type of file it is." % file_extension) from e - - + @classmethod + def create_from_server_response(cls, requests, url, size=None, type=None): + """Create new Media object, with size and/or type fetched from the + server when not given. + + :param requests: Either the requests module itself, or a Session object. + :type requests: requests + :param url: The URL at which the media can be accessed right now. + :type url: str + :param size: Size of the file. Will be fetched from server if not given. + :type size: int or None + :param type: The media type of the file. Will be fetched from server if + not given. + :type type: str or None + :returns: New instance of Media with all fields filled in. + :raises: The appropriate requests exceptions are thrown when networking + errors occur. RuntimeError is thrown if some information isn't + given and isn't found in the server's response.""" + # TODO: Create unit tests for this factory (it is not covered yet!) + if not (size and type): + r = requests.head(url, allow_redirects=True, timeout=5.0, + headers={"User-Agent": version.name + " v" + + version.version_full_str}) + r.raise_for_status() + if not size: + try: + size = r.headers['Content-Length'] + except KeyError: + raise RuntimeError("Content-Length not returned by server " + "when sending HEAD request to %s" % url) + if not type: + try: + type = r.headers['Content-Type'] + except KeyError: + raise RuntimeError("Content-Type header not returned by " + "server when sending HEAD request to %s" + % url) + + return Media(url, size, type) + + def __str__(self): + return "Media(url=%s, size=%s, type=%s)" % \ + (self.url, self.size, self.type) + + def __repr__(self): + return self.__str__() From f873345d2a694145d9dd3dd4b5983abc3973ea5c Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 11:29:45 +0200 Subject: [PATCH 059/200] Give TestCase classes decriptive names and streamline make test Instead of calling python -m unittest for each single test module, call it once with all modules. This saves time and makes the output much cleaner. Also revert the changes to Makefile in 8f09d3ee2cb so that the recipe for "make test" failes when the tests fail. This way, make test will return non-zero status code on error (instead of always returning zero). --- Makefile | 9 +++------ feedgen/tests/test_entry.py | 2 +- feedgen/tests/test_feed.py | 2 +- feedgen/tests/test_media.py | 2 +- feedgen/tests/test_person.py | 2 +- feedgen/tests/test_util.py | 2 +- 6 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 5c38c51..eeb612b 100644 --- a/Makefile +++ b/Makefile @@ -42,10 +42,7 @@ 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_person - -python -m unittest feedgen.tests.test_media - -python -m unittest feedgen.tests.test_util - -python -m feedgen rss > /dev/null + python -m unittest feedgen.tests.test_feed feedgen.tests.test_entry \ + feedgen.tests.test_person feedgen.tests.test_media feedgen.tests.test_util + python -m feedgen rss > /dev/null @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 9ed1510..7a79ffb 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -15,7 +15,7 @@ import pytz from dateutil.parser import parse as parsedate -class TestSequenceFunctions(unittest.TestCase): +class TestBaseEpisode(unittest.TestCase): def setUp(self): diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 33be12e..dd14ffb 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -17,7 +17,7 @@ import dateutil.tz import dateutil.parser -class TestSequenceFunctions(unittest.TestCase): +class TestPodcast(unittest.TestCase): def setUp(self): diff --git a/feedgen/tests/test_media.py b/feedgen/tests/test_media.py index afe6087..ed0b6ff 100644 --- a/feedgen/tests/test_media.py +++ b/feedgen/tests/test_media.py @@ -5,7 +5,7 @@ from feedgen.media import Media from feedgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning -class TestSequenceFunctions(unittest.TestCase): +class TestMedia(unittest.TestCase): def setUp(self): self.url = "http://example.com/2016/5/17/The+awesome+episode.mp3" self.size = 144253424 diff --git a/feedgen/tests/test_person.py b/feedgen/tests/test_person.py index 7d444d8..24930c1 100644 --- a/feedgen/tests/test_person.py +++ b/feedgen/tests/test_person.py @@ -1,7 +1,7 @@ import unittest from ..person import Person -class TestSequenceFunctions(unittest.TestCase): +class TestPerson(unittest.TestCase): def setUp(self): self.name = "Test Person" self.email = "test@example.org" diff --git a/feedgen/tests/test_util.py b/feedgen/tests/test_util.py index 2f0f9b2..9068bc0 100644 --- a/feedgen/tests/test_util.py +++ b/feedgen/tests/test_util.py @@ -1,7 +1,7 @@ import unittest from feedgen import util -class TestSequenceFunctions(unittest.TestCase): +class TestUtil(unittest.TestCase): def test_listToHumanReadableStr(self): # Just check that none of the cases causes an error From f8733e8b571636b7ce30139daefe491173cd2bf2 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 12:25:04 +0200 Subject: [PATCH 060/200] Ensure Media's attributes cannot be set to None --- feedgen/media.py | 21 +++++++++++++++----- feedgen/tests/test_media.py | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/feedgen/media.py b/feedgen/media.py index e33dd15..fa9b5a3 100644 --- a/feedgen/media.py +++ b/feedgen/media.py @@ -43,6 +43,9 @@ class Media(object): * PDF * EPUB + All attributes will always have a value, except size which can be 0 if the + size cannot be determined by any means (eg. if it's a stream). + """ file_types = { @@ -95,8 +98,10 @@ def size(self): You can either provide the number of bytes as an int, or you can provide a human-readable str with a unit, like MB or GiB. - An unknown size is represented as 0. You should strive to provide a - size, so your listeners aren't surprised by any big files. + An unknown size is represented as 0. This should ONLY be used in + exceptional cases, where it is theoretically impossible to determine + the file size (for example if it's a stream). Setting the size to 0 + will issue a UserWarning. .. note:: @@ -120,6 +125,10 @@ def size(self, size): raise ValueError("File size must be 0 if unknown, or a positive" " integer.") self._size = size + if self.size == 0: + warnings.warn("Size is set to 0. This should ONLY be done when " + "there is no possible way to determine the " + "media's size, like if the media is a stream.") except ValueError: self.size = self._str_to_bytes(size) except TypeError as e: @@ -127,7 +136,6 @@ def size(self, size): self.size = 0 else: raise e - @staticmethod def _str_to_bytes(size): units = { @@ -151,7 +159,7 @@ def _str_to_bytes(size): @property def type(self): - """The media type of this media. + """The MIME type of this media. See https://en.wikipedia.org/wiki/Media_type for an introduction. @@ -168,9 +176,12 @@ def type(self): @type.setter def type(self, type): + if not type: + raise ValueError("Type cannot be empty or None") + type = type.strip().lower() - if not type in self.file_types.values(): + if type not in self.file_types.values(): warnings.warn("Media type %s is not supported by iTunes.", NotSupportedByItunesWarning) self._type = type diff --git a/feedgen/tests/test_media.py b/feedgen/tests/test_media.py index ed0b6ff..13c6072 100644 --- a/feedgen/tests/test_media.py +++ b/feedgen/tests/test_media.py @@ -36,6 +36,11 @@ def test_assigningUrl(self): another_url = "http://example.com/2016/5/17/The+awful+episode.mp3" m.url = another_url assert m.url == another_url + # Test that setting url to None or empty string fails + self.assertRaises((ValueError, TypeError), setattr, m, "url", None) + assert m.url == another_url + self.assertRaises((ValueError, TypeError), setattr, m, "url", "") + assert m.url == another_url def test_assigningSize(self): m = Media(self.url, self.size) @@ -43,11 +48,44 @@ def test_assigningSize(self): m.size = another_size assert m.size == another_size + def test_warningWhenSettingSizeToZero(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.assertEqual(len(w), 0) + + # Set size to zero, triggering a warning + m = Media(self.url, type=self.type) + self.assertEqual(len(w), 1) + assert issubclass(w[-1].category, UserWarning) + + # No warning when setting to an actual integer + m.size = 253634535 + self.assertEqual(len(w), 1) + + # Nor when using a string + m.size = "15kB" + self.assertEqual(len(w), 1) + + # Warning when setting to None + m.size = None + self.assertEqual(len(w), 2) + assert issubclass(w[-1].category, UserWarning) + + # Or zero + m.size = 0 + self.assertEqual(len(w), 3) + assert issubclass(w[-1].category, UserWarning) + def test_assigningType(self): m = Media(self.url, self.size, self.type) another_type = "audio/x-mpeg-3" m.type = another_type assert m.type == another_type + # Test that setting type to None or empty string fails + self.assertRaises((ValueError, TypeError), setattr, m, "type", None) + assert m.type == another_type + self.assertRaises((ValueError, TypeError), setattr, m, "type", "") + assert m.type == another_type def test_autoRecognizeType(self): url = "http://example.com/2016/5/17/The+converted+episode%s" From e419872838c18a024ff5c072ae5e436c8ecae8ea Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 12:43:56 +0200 Subject: [PATCH 061/200] Improve documentation of Media --- feedgen/media.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/feedgen/media.py b/feedgen/media.py index fa9b5a3..0ae16c6 100644 --- a/feedgen/media.py +++ b/feedgen/media.py @@ -72,8 +72,8 @@ def url(self): """The URL at which this media is publicly accessible. Only absolute URLs are allowed, so make sure it starts with http:// or - https://. The server should support HEAD-requests in addition to - multi-range requests.""" + https://. The server should support HEAD-requests and byte-range + requests.""" return self._url @url.setter @@ -221,7 +221,24 @@ def create_from_server_response(cls, requests, url, size=None, type=None): """Create new Media object, with size and/or type fetched from the server when not given. - :param requests: Either the requests module itself, or a Session object. + Like the signature suggests, this factory method requires that + `Requests `_ is installed. + + Example (assuming the server responds with Content-Length: 252345991 and + Content-Type: audio/mpeg):: + + >>> from feedgen.media import Media + >>> import requests # from requests package + >>> # Assume an episode is hosted at example.com + >>> m = Media.create_from_server_response(requests, + ... "http://example.com/episodes/ep1.mp3") + >>> m + Media(url=http://example.com/episodes/ep1.mp3, size=252345991, type=audio/mpeg) + + + :param requests: Either the + `requests `_ module + itself, or a Session object. :type requests: requests :param url: The URL at which the media can be accessed right now. :type url: str From e43318dc0b67c88faf6b03b5e1147700c06706d9 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 13:07:04 +0200 Subject: [PATCH 062/200] Change BaseEpisode.enclosure to use Media --- feedgen/__main__.py | 2 ++ feedgen/item.py | 36 +++++++++++++++++++++--------------- feedgen/media.py | 3 ++- feedgen/tests/test_entry.py | 21 +++++++++++++++++++-- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 3d7d4d6..1df1779 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -44,6 +44,7 @@ def main(): from feedgen.feed import Podcast from feedgen.person import Person + from feedgen.media import Media # Initialize the feed p = Podcast() p.name('Testfeed') @@ -71,6 +72,7 @@ def main(): e1.link(href='http://example.com') e1.authors = [Person('Lars Kiesow', 'lkiesow@uos.de')] e1.published(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) + e1.enclosure(Media("http://example.com/episodes/loremipsum.mp3", 454599964)) # Should we just print out, or write to file? if arg == 'rss': diff --git a/feedgen/item.py b/feedgen/item.py index 80eb3a7..5fc647d 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -101,7 +101,7 @@ def rss_entry(self, extensions=True): if self.__rss_guid: rss_guid = self.__rss_guid elif self.__rss_enclosure and self.__rss_guid is None: - rss_guid = self.__rss_enclosure['url'] + rss_guid = self.__rss_enclosure.url else: # self.__rss_guid was set to boolean False, or no enclosure rss_guid = None @@ -112,9 +112,9 @@ def rss_entry(self, extensions=True): if self.__rss_enclosure: enclosure = etree.SubElement(entry, 'enclosure') - enclosure.attrib['url'] = self.__rss_enclosure['url'] - enclosure.attrib['length'] = str(self.__rss_enclosure['length']) - enclosure.attrib['type'] = self.__rss_enclosure['type'] + enclosure.attrib['url'] = self.__rss_enclosure.url + enclosure.attrib['length'] = str(self.__rss_enclosure.size) + enclosure.attrib['type'] = self.__rss_enclosure.type if self.__rss_pubDate: pubDate = etree.SubElement(entry, 'pubDate') @@ -299,9 +299,9 @@ def published(self, published=None): return self.__rss_pubDate - 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 this item. + def enclosure(self, media=None): + """Get or set the :class:`~feedgen.media.Media` object that is attached + to this episode. Note that if :py:meth:`.id` is not set, the enclosure's url is used as the globally unique identifier. If you rely on this, you should make @@ -309,16 +309,22 @@ def enclosure(self, url=None, length=None, type=None): (they will think this episode is new again, even if the user already has listened to it). Therefore, you should only rely on this behaviour if you own the domain which the episodes reside on. If you don't, then - you must set :py:meth:`.id` to something which is unique not only for - this podcast, but for all podcasts. + you must set :py:meth:`.id` to an appropriate value manually. - :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. + :param media: The Media object which points to the media file you want + to attach to this episode. + :type media: feedgen.media.Media or None + :returns: The media file attached to this episode. """ - if not url is None: - self.__rss_enclosure = {'url': url, 'length': length, 'type': type} + if not media is None: + # Test that the media quacks like a duck + if hasattr(media, "url") and hasattr(media, "size") and \ + hasattr(media, "type"): + # It's a duck + self.__rss_enclosure = media + else: + raise TypeError("The parameter media must have the attributes " + "url, size and type.") return self.__rss_enclosure def itunes_block(self, itunes_block=None): diff --git a/feedgen/media.py b/feedgen/media.py index 0ae16c6..907de98 100644 --- a/feedgen/media.py +++ b/feedgen/media.py @@ -128,7 +128,8 @@ def size(self, size): if self.size == 0: warnings.warn("Size is set to 0. This should ONLY be done when " "there is no possible way to determine the " - "media's size, like if the media is a stream.") + "media's size, like if the media is a stream.", + stacklevel=3) except ValueError: self.size = self._str_to_bytes(size) except TypeError as e: diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 7a79ffb..90f77d0 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -10,6 +10,7 @@ from lxml import etree from feedgen.person import Person +from feedgen.media import Media from ..feed import Podcast import datetime import pytz @@ -96,7 +97,7 @@ def test_idNotSetButEnclosureIsUsed(self): guid = "http://example.com/podcast/episode1.mp3" episode = self.fg.Episode() episode.title("My first episode") - episode.enclosure(guid, 0, "audio/mpeg") + episode.enclosure(Media(guid, 97423487, "audio/mpeg")) item = episode.rss_entry() assert item.find("guid").text == guid @@ -104,7 +105,8 @@ def test_idNotSetButEnclosureIsUsed(self): def test_idSetToFalseSoEnclosureNotUsed(self): episode = self.fg.Episode() episode.title("My first episode") - episode.enclosure("http://example.com/podcast/episode1.mp3", 0, "audio/mpeg") + episode.enclosure(Media("http://example.com/podcast/episode1.mp3", + 34328731, "audio/mpeg")) episode.id(False) item = episode.rss_entry() @@ -221,3 +223,18 @@ def test_authorsInvalidAssignment(self): def do_authorsInvalidAssignment(self): self.fe.authors = Person("Oh No", "notan@iterable.no") + + def test_media(self): + media = Media("http://example.org/episodes/1.mp3", 14536453, + "audio/mpeg") + self.fe.enclosure(media) + enclosure = self.fe.rss_entry().find("enclosure") + + self.assertEqual(enclosure.get("url"), media.url) + self.assertEqual(enclosure.get("length"), str(media.size)) + self.assertEqual(enclosure.get("type"), media.type) + + # Ensure duck-typing is checked at assignment time + self.assertRaises(TypeError, self.fe.enclosure, media.url) + self.assertRaises(TypeError, self.fe.enclosure, + (media.url, media.size, media.type)) From 8806a5f2a23049840d95b57904632affa01a3c89 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 15:09:46 +0200 Subject: [PATCH 063/200] Implement Category class --- Makefile | 5 +- doc/api.category.rst | 5 ++ doc/api.rst | 21 +++---- feedgen/category.py | 110 +++++++++++++++++++++++++++++++++ feedgen/tests/test_category.py | 42 +++++++++++++ 5 files changed, 168 insertions(+), 15 deletions(-) create mode 100644 doc/api.category.rst create mode 100644 feedgen/category.py create mode 100644 feedgen/tests/test_category.py diff --git a/Makefile b/Makefile index eeb612b..c563d31 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,8 @@ publish: sdist python setup.py register sdist upload test: - python -m unittest feedgen.tests.test_feed feedgen.tests.test_entry \ - feedgen.tests.test_person feedgen.tests.test_media feedgen.tests.test_util + @python -m unittest feedgen.tests.test_feed feedgen.tests.test_entry \ + feedgen.tests.test_person feedgen.tests.test_media \ + feedgen.tests.test_util feedgen.tests.test_category python -m feedgen rss > /dev/null @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml diff --git a/doc/api.category.rst b/doc/api.category.rst new file mode 100644 index 0000000..3bd3d35 --- /dev/null +++ b/doc/api.category.rst @@ -0,0 +1,5 @@ +feedgen.category.Category +========================= + +.. autoclass:: feedgen.category.Category + :members: diff --git a/doc/api.rst b/doc/api.rst index a9d4815..19fb531 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -24,20 +24,14 @@ The unit tests reside in ``feedgen/tests`` and are written using the API Documentation ----------------- -:class:`feedgen.feed.Podcast` (available as ``feedgen.Podcast``) is the corner -stone of PodcastGenerator. You create one instance of it for each feed you want -to make. - -When you create episodes for a Podcast, you're most likely creating new -instances of :class:`feedgen.item.BaseEpisode`. - -You use :class:`feedgen.person.Person` whenever an attribute is to represent -a person or an entity, and :class:`feedgen.media.Media` with the -:attr:`feedgen.item.BaseEpisode.enclosure` attribute. - -:mod:`feedgen.util` provides utility functions for the rest of the library, -and is therefore not relevant for users. +.. autosummary:: + feedgen.feed.Podcast + feedgen.item.BaseEpisode + feedgen.person.Person + feedgen.media.Media + feedgen.category.Category + feedgen.util .. toctree:: :maxdepth: 2 @@ -47,4 +41,5 @@ and is therefore not relevant for users. api.item api.person api.media + api.category api.util diff --git a/feedgen/category.py b/feedgen/category.py new file mode 100644 index 0000000..9dc3fb2 --- /dev/null +++ b/feedgen/category.py @@ -0,0 +1,110 @@ +class Category(object): + """Immutable class representing an iTunes category. + + See https://help.apple.com/itc/podcasts_connect/#/itc9267a2f12 for an + overview of the available categories and their subcategories. + + .. note:: + + The categories are case-insensitive, and you may escape ampersands. + The category and subcategory will end up properly capitalized and + with unescaped ampersands. + + Example:: + + >>> from feedgen.category import Category + >>> c = Category("Music") + >>> c.category + Music + >>> c.subcategory + None + >>> + >>> d = Category("games & hobbies", "Video games") + >>> d.category + Games & Hobbies + >>> d.subcategory + Video Games + """ + + _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': [] + } + + def __init__(self, category, subcategory=None): + """Create new Category object. See the class description of + :class:´~feedgen.category.Category`. + + :param category: Category of the podcast. + :type category: str + :param subcategory: (Optional) Subcategory of the podcast. + :type subcategory: str or None + """ + if not category: + raise TypeError("category must be provided, was \"%s\"" % category) + # Do a case-insensitive search for the category + search_category = category.strip().replace("&", "&").lower() + for actual_category in self._categories: + if actual_category.lower() == search_category: + # We found it + canonical_category = actual_category + break + else: # no break + raise ValueError('Invalid category "%s"' % category) + self.__category = canonical_category + + # Do a case-insensitive search for the subcategory, if provided + canonical_subcategory = None + if subcategory is not None: + search_subcategory = subcategory.strip().replace("&", "&")\ + .lower() + for actual_subcategory in self._categories[canonical_category]: + if actual_subcategory.lower() == search_subcategory: + canonical_subcategory = actual_subcategory + break + else: # no break + raise ValueError('Invalid subcategory "%s" under category "%s"' + % (subcategory, canonical_category)) + + self.__subcategory = canonical_subcategory + + @property + def category(self): + """The category represented by this object. Read-only.""" + return self.__category + # Make this attribute read-only by not implementing setter + + @property + def subcategory(self): + """The subcategory this object represents. Read-only.""" + return self.__subcategory + # Make this attribute read-only by not implementing setter + + def __repr__(self): + return 'Category(category=%s, subcategory=%s)' % \ + (self.category, self.subcategory) diff --git a/feedgen/tests/test_category.py b/feedgen/tests/test_category.py new file mode 100644 index 0000000..179eb94 --- /dev/null +++ b/feedgen/tests/test_category.py @@ -0,0 +1,42 @@ +import unittest + +from feedgen.category import Category + + +class TestCategory(unittest.TestCase): + def test_constructorWithSubcategory(self): + c = Category("Arts", "Food") + self.assertEqual(c.category, "Arts") + self.assertEqual(c.subcategory, "Food") + + def test_constructorWithoutSubcategory(self): + c = Category("Arts") + self.assertEqual(c.category, "Arts") + self.assertTrue(c.subcategory is None) + + def test_constructorInvalidCategory(self): + self.assertRaises(ValueError, Category, "Farts", "Food") + + def test_constructorInvalidSubcategory(self): + self.assertRaises(ValueError, Category, "Arts", "Flood") + + def test_constructorSubcategoryWithoutCategory(self): + self.assertRaises((ValueError, TypeError), Category, None, "Food") + + def test_constructorCaseInsensitive(self): + c = Category("arTS", "FOOD") + self.assertEqual(c.category, "Arts") + self.assertEqual(c.subcategory, "Food") + + def test_immutable(self): + c = Category("Arts", "Food") + self.assertRaises(AttributeError, setattr, c, "category", "Technology") + self.assertEqual(c.category, "Arts") + + self.assertRaises(AttributeError, setattr, c, "subcategory", "Design") + self.assertEqual(c.subcategory, "Food") + + def test_escapedIsAccepted(self): + c = Category("Sports & Recreation", "College & High School") + self.assertEqual(c.category, "Sports & Recreation") + self.assertEqual(c.subcategory, "College & High School") From 4766f212d855bd9fc1871d75320701cb72c431e6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 16:03:40 +0200 Subject: [PATCH 064/200] Switch to Category in Podcast.itunes_category --- feedgen/__main__.py | 3 +- feedgen/feed.py | 68 ++++++++++---------------------------- feedgen/tests/test_feed.py | 27 +++++++++++++++ 3 files changed, 46 insertions(+), 52 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 1df1779..37cd09d 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -45,6 +45,7 @@ def main(): from feedgen.feed import Podcast from feedgen.person import Person from feedgen.media import Media + from feedgen.category import Category # Initialize the feed p = Podcast() p.name('Testfeed') @@ -54,7 +55,7 @@ def main(): p.description('This is a cool feed!') p.language('de') p.feed_url('http://example.com/feeds/myfeed.rss') - p.itunes_category('Technology', 'Podcasting') + p.itunes_category(Category('Technology', 'Podcasting')) p.itunes_explicit('no') p.itunes_complete('no') p.itunes_new_feed_url('http://example.com/new-feed.rss') diff --git a/feedgen/feed.py b/feedgen/feed.py index a80eb5d..4442afd 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -301,10 +301,10 @@ def _create_rss(self): 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'): + category.attrib['text'] = self.__itunes_category.category + if self.__itunes_category.subcategory: subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) - subcategory.attrib['text'] = self.__itunes_category['sub'] + subcategory.attrib['text'] = self.__itunes_category.subcategory if self.__itunes_image: image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) @@ -731,60 +731,26 @@ 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): - """Get or set the ITunes category which appears in the category column + def itunes_category(self, category=None): + """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 + Use the :class:`feedgen.category.Category` class. - :param itunes_category: Category of the podcast, unescaped. - :type itunes_category: str - :param itunes_subcategory: Subcategory of the podcast, unescaped. The subcategory need not be set. - :type itunes_subcategory: str - :returns: Dictionary which has category with key 'cat', and optionally subcategory with key 'sub'. + :param category: This podcast's category. + :type category: feedgen.category.Category or None + :returns: This podcast's category. """ - if not itunes_category is None: - if not itunes_category in self._itunes_categories.keys(): - raise ValueError('Invalid category %s' % itunes_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 "%s" under category "%s"' - % (itunes_subcategory, itunes_category)) - cat['sub'] = itunes_subcategory - self.__itunes_category = cat + if not category is None: + # Check that the category quacks like a duck + if hasattr(category, "category") and \ + hasattr(category, "subcategory"): + self.__itunes_category = category + else: + raise TypeError("A Category(-like) object must be used, got " + "%s" % category) return self.__itunes_category - _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': [] - } def itunes_image(self, itunes_image=None): """Get or set the image for the podcast. This tag specifies the artwork diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index dd14ffb..a4ac74e 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -11,6 +11,7 @@ from lxml import etree from feedgen.person import Person +from feedgen.category import Category from ..feed import Podcast import feedgen.version import datetime @@ -269,5 +270,31 @@ def test_webMaster(self): " (" + self.fg.webMaster().name + ")", channel.find("webMaster").text) + def test_categoryWithoutSubcategory(self): + c = Category("Arts") + self.fg.itunes_category(c) + channel = self.fg._create_rss().find("channel") + itunes_category = channel.find("{%s}category" % self.nsItunes) + assert itunes_category is not None + + self.assertEqual(itunes_category.get("text"), c.category) + + assert itunes_category.find("{%s}category" % self.nsItunes) is None + + def test_categoryWithSubcategory(self): + c = Category("Arts", "Food") + self.fg.itunes_category(c) + channel = self.fg._create_rss().find("channel") + itunes_category = channel.find("{%s}category" % self.nsItunes) + assert itunes_category is not None + itunes_subcategory = itunes_category\ + .find("{%s}category" % self.nsItunes) + assert itunes_subcategory is not None + self.assertEqual(itunes_subcategory.get("text"), c.subcategory) + + def test_categoryChecks(self): + c = ("Arts", "Food") + self.assertRaises(TypeError, self.fg.itunes_category, c) + if __name__ == '__main__': unittest.main() From b0d08776e4dad29ecccdfd08d89bae3caee34366 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 16:12:12 +0200 Subject: [PATCH 065/200] Rename Podcast.itunes_category() to Podcast.category() --- feedgen/__main__.py | 2 +- feedgen/feed.py | 2 +- feedgen/tests/test_feed.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 37cd09d..3909069 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -55,7 +55,7 @@ def main(): p.description('This is a cool feed!') p.language('de') p.feed_url('http://example.com/feeds/myfeed.rss') - p.itunes_category(Category('Technology', 'Podcasting')) + p.category(Category('Technology', 'Podcasting')) p.itunes_explicit('no') p.itunes_complete('no') p.itunes_new_feed_url('http://example.com/new-feed.rss') diff --git a/feedgen/feed.py b/feedgen/feed.py index 4442afd..bac5810 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -731,7 +731,7 @@ def itunes_block(self, itunes_block=None): self.__itunes_block = itunes_block return self.__itunes_block - def itunes_category(self, category=None): + def category(self, category=None): """Get or set the iTunes category, which appears in the category column and in iTunes Store Browser. diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index a4ac74e..6dfaa92 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -272,7 +272,7 @@ def test_webMaster(self): def test_categoryWithoutSubcategory(self): c = Category("Arts") - self.fg.itunes_category(c) + self.fg.category(c) channel = self.fg._create_rss().find("channel") itunes_category = channel.find("{%s}category" % self.nsItunes) assert itunes_category is not None @@ -283,7 +283,7 @@ def test_categoryWithoutSubcategory(self): def test_categoryWithSubcategory(self): c = Category("Arts", "Food") - self.fg.itunes_category(c) + self.fg.category(c) channel = self.fg._create_rss().find("channel") itunes_category = channel.find("{%s}category" % self.nsItunes) assert itunes_category is not None @@ -294,7 +294,7 @@ def test_categoryWithSubcategory(self): def test_categoryChecks(self): c = ("Arts", "Food") - self.assertRaises(TypeError, self.fg.itunes_category, c) + self.assertRaises(TypeError, self.fg.category, c) if __name__ == '__main__': unittest.main() From 43d4a59c11aa30767d77148724088cea1346597e Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 16:59:36 +0200 Subject: [PATCH 066/200] Update Podcast.itunes_explicit to be boolean and mandatory iTunes will not accept a podcast without the itunes:explicit tag. It has therefore been made mandatory. It has also been made into a boolean. Earlier, a third state ("blank") was allowed, but now it must be either explicit or clean, so it made more sense with a boolean. --- doc/user/basic_usage_guide/part_1.rst | 11 +++--- feedgen/feed.py | 52 ++++++++++++++------------- feedgen/tests/test_entry.py | 2 ++ feedgen/tests/test_feed.py | 48 +++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 29 deletions(-) diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index a12ad89..f835497 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -17,11 +17,13 @@ Mandatory properties p.name = "My Example Podcast" p.description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." p.website = "https://example.org" + p.explicit = True -Those three properties, :attr:`~feedgen.feed.Podcast.name`, -:attr:`~feedgen.feed.Podcast.description` and +Those four properties, :attr:`~feedgen.feed.Podcast.name`, +:attr:`~feedgen.feed.Podcast.description`, +:attr:`~feedgen.feed.Podcast.explicit` and :attr:`~feedgen.feed.Podcast.website`, are actually -the only three **mandatory** properties of +the only four **mandatory** properties of :class:`~feedgen.feed.Podcast`. A summary of them: .. autosummary:: @@ -29,6 +31,7 @@ the only three **mandatory** properties of ~feedgen.feed.Podcast.name ~feedgen.feed.Podcast.description ~feedgen.feed.Podcast.website + ~feedgen.feed.Podcast.explicit Image ~~~~~ @@ -59,7 +62,6 @@ Commonly used p.authors = [p.Person("John Doe", "editor@example.org")] p.feed_url = "https://example.com/feeds/podcast.rss" p.category = Category("Technology", "Podcasting") - p.explicit = True p.owner = p.author .. autosummary:: @@ -69,7 +71,6 @@ Commonly used ~feedgen.feed.Podcast.authors ~feedgen.feed.Podcast.feed_url ~feedgen.feed.Podcast.category - ~feedgen.feed.Podcast.explicit ~feedgen.feed.Podcast.owner diff --git a/feedgen/feed.py b/feedgen/feed.py index bac5810..85a435c 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -28,6 +28,13 @@ class Podcast(object): """Class representing one podcast feed. + + The following attributes are mandatory: + + * :attr:`~feedgen.podcast.Podcast.name` + * :attr:`~feedgen.podcast.Podcast.website` + * :attr:`~feedgen.podcast.Podcast.description` + * :attr:`~feedgen.podcast.Podcast.itunes_explicit` """ @@ -199,10 +206,12 @@ def _create_rss(self): 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'])) + if not (self.__rss_title and self.__rss_link and self.__rss_description + and self.__itunes_explicit is not None): + missing = ', '.join(([] if self.__rss_title else ['title']) + + ([] if self.__rss_link else ['link']) + + ([] if self.__rss_description else ['description']) + + ([] if self.__itunes_explicit else ['itunes_explicit'])) raise ValueError('Required fields not set (%s)' % missing) title = etree.SubElement(channel, 'title') title.text = self.__rss_title @@ -210,6 +219,8 @@ def _create_rss(self): link.text = self.__rss_link desc = etree.SubElement(channel, 'description') desc.text = self.__rss_description + explicit = etree.SubElement(channel, '{%s}explicit' % ITUNES_NS) + explicit.text = "yes" if self.__itunes_explicit else "no" if self.__rss_cloud: cloud = etree.SubElement(channel, 'cloud') @@ -310,10 +321,6 @@ def _create_rss(self): 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 @@ -782,26 +789,23 @@ def itunes_image(self, itunes_image=None): 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". + """Get or set whether this podcast may be inappropriate for children or + not. + + This is one of the mandatory attributes. - If you populate this tag with "yes", an "explicit" parental advisory + If you set this to ``True``, 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts - as blank. - :type itunes_explicit: str - :returns: If the podcast contains explicit material. + in the Name column in iTunes. If it is set to ``False``, + 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. + + :param itunes_explicit: True if explicit, False if not. + :type itunes_explicit: bool or None + :returns: Whether the podcast contains explicit material or not. """ if not itunes_explicit is None: - if not itunes_explicit in ('', 'yes', 'no', 'clean'): - raise ValueError('Invalid value "%s" for explicit tag' % itunes_explicit) self.__itunes_explicit = itunes_explicit return self.__itunes_explicit diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 90f77d0..01ae3f4 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -27,10 +27,12 @@ def setUp(self): self.title = 'Some Testfeed' self.link = 'http://lernfunk.de' self.description = 'A cool tent' + self.explicit = False fg.name(self.title) fg.website(self.link) fg.description(self.description) + fg.itunes_explicit(self.explicit) fe = fg.add_episode() fe.id('http://lernfunk.de/media/654321/1') diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 6dfaa92..ab64bfd 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -50,6 +50,8 @@ def setUp(self): self.skipDays = 'Tuesday' self.skipHours = 23 + self.explicit = False + self.programname = feedgen.version.name self.webMaster = Person(email='webmaster@example.com') @@ -67,6 +69,7 @@ def setUp(self): fg.skipHours(self.skipHours) fg.webMaster(self.webMaster) fg.feed_url(self.feedUrl) + fg.itunes_explicit(self.explicit) self.fg = fg @@ -296,5 +299,50 @@ def test_categoryChecks(self): c = ("Arts", "Food") self.assertRaises(TypeError, self.fg.category, c) + def test_explicitIsExplicit(self): + self.fg.itunes_explicit(True) + channel = self.fg._create_rss().find("channel") + itunes_explicit = channel.find("{%s}explicit" % self.nsItunes) + assert itunes_explicit is not None + assert itunes_explicit.text.lower() in ("yes", "explicit", "true"),\ + "itunes:explicit was %s, expected yes, explicit or true" \ + % itunes_explicit.text + + def test_explicitIsClean(self): + self.fg.itunes_explicit(False) + channel = self.fg._create_rss().find("channel") + itunes_explicit = channel.find("{%s}explicit" % self.nsItunes) + assert itunes_explicit is not None + assert itunes_explicit.text.lower() in ("no", "clean", "false"),\ + "itunes:explicit was %s, expected no, clean or false" \ + % itunes_explicit.text + + def test_mandatoryValues(self): + # Try to create a Podcast once for each mandatory property. + # On each iteration, exactly one of the properties is not set. + # Therefore, an exception should be thrown on each iteration. + mandatory_properties = { + "description", + "title", + "link", + "itunes_explicit", + } + + for test_property in mandatory_properties: + fg = Podcast() + if test_property != "description": + fg.description(self.description) + if test_property != "title": + fg.name(self.title) + if test_property != "link": + fg.website(self.linkHref) + if test_property != "itunes_explicit": + fg.itunes_explicit(self.explicit) + try: + self.assertRaises(ValueError, fg._create_rss) + except AssertionError as e: + raise AssertionError("The test failed for %s" % test_property)\ + from e + if __name__ == '__main__': unittest.main() From a0b8f87b892c6987150875a0e77e2da3eb0306ef Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 18:57:45 +0200 Subject: [PATCH 067/200] Fix using string, not boolean for itunes_explicit in __main__.py --- feedgen/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 3909069..8623736 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -56,7 +56,7 @@ def main(): p.language('de') p.feed_url('http://example.com/feeds/myfeed.rss') p.category(Category('Technology', 'Podcasting')) - p.itunes_explicit('no') + p.itunes_explicit(False) p.itunes_complete('no') p.itunes_new_feed_url('http://example.com/new-feed.rss') p.itunes_owner(Person('John Doe', 'john@example.com')) From a77a499ab60e064c0ed15c0cf4f7e9602dc3c6f3 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 19:30:06 +0200 Subject: [PATCH 068/200] Make the proposed API a bit prettier Also, improve the documentation a bit. --- doc/user/basic_usage_guide/part_1.rst | 10 ++--- doc/user/basic_usage_guide/part_2.rst | 60 ++++++++++++++------------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index f835497..4ceb61f 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -57,12 +57,12 @@ Commonly used :: - p.copyright = "© 2016 Example Radio" + p.copyright = "2016 Example Radio" p.language = "en-US" - p.authors = [p.Person("John Doe", "editor@example.org")] + p.authors = [Person("John Doe", "editor@example.org")] p.feed_url = "https://example.com/feeds/podcast.rss" p.category = Category("Technology", "Podcasting") - p.owner = p.author + p.owner = p.authors[0] .. autosummary:: @@ -83,7 +83,7 @@ full description. :: - p.cloud = p.CloudService("server.example.com", "/rpc", 80, "xml-rpc") + p.cloud = ("server.example.com", "/rpc", 80, "xml-rpc") import datetime import pytz @@ -93,7 +93,7 @@ full description. p.skipDays = {"Friday", "Saturday", "Sunday"} p.skipHours = set(range(8)) p.skipHours |= set(range(16, 24)) - p.webMaster = p.Person(None, "helpdesk@dallas.example.com") + p.webMaster = Person(None, "helpdesk@dallas.example.com") # Be very careful about using the following attributes: p.new_feed_url = "https://podcast.example.com/example" p.complete = True diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index fccd0c3..c78d3e8 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -3,22 +3,23 @@ Adding episodes --------------- To add episodes to a feed, you need to create new -:attr:`Podcast.Episode ` objects and +:class:`feedgen.episode.Episode` objects and append them to the list of entries in the Podcast. That is pretty straight-forward:: - my_episode = p.Episode() + from feedgen.episode import Episode + my_episode = Episode() p.episodes.append(my_episode) -There is a conveinence method called :meth:`Podcast.add_episode ` -which optionally creates a new instance of ``Episode``, adds it to the podcast +There is a convenience method called :meth:`Podcast.add_episode ` +which optionally creates a new instance of :class:`~feedgen.episode.Episode`, adds it to the podcast and returns it, allowing you to assign it to a variable:: my_episode = p.add_episode() If you prefer to use the constructor, there's nothing wrong with that:: - my_episode = p.add_episode(p.Episode()) + my_episode = p.add_episode(Episode()) The advantage of using the latter form, is that you can pass data to the constructor, which can make your code more compact and readable. @@ -55,25 +56,36 @@ Enclosing media Of course, this isn't much of a podcast if we don't have any **media** attached to it! :: - my_episode.media = p.Media("http://example.com/podcast/s01e10.mp3", - size=p.Media.Auto, - duration="1:02:36") + from datetime import timedelta + my_episode.media = Media("http://example.com/podcast/s01e10.mp3", + size=17475653, + type="audio/mpeg", # Optional, can be determined + # from the url + duration=timedelta(hours=1, minutes=2, seconds=36) + ) + +Normally, you must specify how big the **file size** is in bytes (and the MIME +type, if the file extension is unknown to iTunes), but PodcastGenerator +can send a HEAD request to the URL and retrieve the missing information. This is +done by calling :meth:`Media.create_from_server_response ` +instead of using the constructor directly. +You must pass in the `requests `_ +module, so it must be installed! :: + + import requests + my_episode.media = Media.create_from_server_response( + requests, + "http://example.com/podcast/s01e10.mp3", + duration=timedelta(hours=1, minutes=2, seconds=36) + ) -Normally, you must specify how big the **file size** is in bytes, but PodcastGenerator -can send a HEAD request to the URL and retrieve how many bytes it is -automatically by using p.Media.Auto as shown. This only works if `requests `_ -is installed, though! If you know how big it is, you're better off not using -this feature, like this:: - - my_episode.media = p.Media("http://example.com/podcast/s01e10.mp3", - size=17475653, - duration="1:02:36") The **type** of the media file is derived from the URI ending. Even though you technically can have file names which don't end in their actual file extension, iTunes will use the file extension to determine what type of file it is, without even asking the server. You must therefore make sure your media files have the -correct file extension. +correct file extension. If you don't care about compatibility with iTunes, you +can provide the MIME type yourself. The **duration** is also important to include, for your listeners' convenience. Without it, they won't know how long an episode is before they start downloading @@ -82,7 +94,7 @@ and listening. .. autosummary:: ~feedgen.item.BaseEpisode.media - ~feedgen.feed.Podcast.Media + ~feedgen.feed.media.Media Identifying the episode @@ -172,16 +184,6 @@ You can even have multiple authors:: .. autosummary:: ~feedgen.item.BaseEpisode.authors -Category -^^^^^^^^ - -An episode can have a different category than the rest of the podcast:: - - my_episode.category = Category("Arts", "Food") - -.. autosummary:: ~feedgen.item.BaseEpisode.category - - Less used attributes ^^^^^^^^^^^^^^^^^^^^ From c83d5deb0bb3788c24e6ba1e5dbab48ad85f21d8 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 21:27:27 +0200 Subject: [PATCH 069/200] Make HUGE changes in Podcast's API, so attributes have better names Instead of having names that reflect their underlying RSS element, the attribute names are renamed so their name best reflects their meaning. Needless to say, that required some refactoring. Some documentation is improved and added. Internal variables have been renamed to reflect their public names. --- doc/user/basic_usage_guide/part_1.rst | 24 +- doc/user/basic_usage_guide/part_2.rst | 4 +- feedgen/__main__.py | 8 +- feedgen/feed.py | 445 ++++++++++++++------------ feedgen/tests/test_entry.py | 6 +- feedgen/tests/test_feed.py | 36 +-- 6 files changed, 276 insertions(+), 247 deletions(-) diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index 4ceb61f..44db799 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -40,7 +40,7 @@ A podcast's image is worth special attention:: p.image = "https://example.com/static/example_podcast.png" -.. automethod:: feedgen.feed.Podcast.itunes_image +.. automethod:: feedgen.feed.Podcast.image :noindex: Even though the image *technically* is optional, you won't reach people without it. @@ -87,13 +87,13 @@ full description. import datetime import pytz - p.updated = datetime.datetime(2016, 5, 18, 0, 0, tzinfo=pytz.utc)) - p.published = datetime.datetime(2016, 5, 17, 15, 32, tzinfo=pytz.utc)) + p.last_updated = datetime.datetime(2016, 5, 18, 0, 0, tzinfo=pytz.utc)) + p.publication_date = datetime.datetime(2016, 5, 17, 15, 32, tzinfo=pytz.utc)) - p.skipDays = {"Friday", "Saturday", "Sunday"} - p.skipHours = set(range(8)) - p.skipHours |= set(range(16, 24)) - p.webMaster = Person(None, "helpdesk@dallas.example.com") + p.skip_days = {"Friday", "Saturday", "Sunday"} + p.skip_hours = set(range(8)) + p.skip_hours |= set(range(16, 24)) + p.web_master = Person(None, "helpdesk@dallas.example.com") # Be very careful about using the following attributes: p.new_feed_url = "https://podcast.example.com/example" p.complete = True @@ -102,11 +102,11 @@ full description. .. autosummary:: ~feedgen.feed.Podcast.cloud - ~feedgen.feed.Podcast.updated - ~feedgen.feed.Podcast.published - ~feedgen.feed.Podcast.skipDays - ~feedgen.feed.Podcast.skipHours - ~feedgen.feed.Podcast.webMaster + ~feedgen.feed.Podcast.last_updated + ~feedgen.feed.Podcast.publication_date + ~feedgen.feed.Podcast.skip_days + ~feedgen.feed.Podcast.skip_hours + ~feedgen.feed.Podcast.web_master ~feedgen.feed.Podcast.new_feed_url ~feedgen.feed.Podcast.complete ~feedgen.feed.Podcast.withhold_from_itunes diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index c78d3e8..ded6384 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -94,7 +94,7 @@ and listening. .. autosummary:: ~feedgen.item.BaseEpisode.media - ~feedgen.feed.media.Media + ~feedgen.media.Media Identifying the episode @@ -168,7 +168,7 @@ The Authors podcast level. Normally, the attributes :attr:`Podcast.authors ` -and :attr:`Podcast.webMaster ` (if set) are +and :attr:`Podcast.web_master ` (if set) are used to determine the authors of an episode. Thus, if all your episodes have the same authors, you should just set it at the podcast level. diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 8623736..80cf457 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -56,10 +56,10 @@ def main(): p.language('de') p.feed_url('http://example.com/feeds/myfeed.rss') p.category(Category('Technology', 'Podcasting')) - p.itunes_explicit(False) - p.itunes_complete('no') - p.itunes_new_feed_url('http://example.com/new-feed.rss') - p.itunes_owner(Person('John Doe', 'john@example.com')) + p.explicit(False) + p.complete('no') + p.new_feed_url('http://example.com/new-feed.rss') + p.owner(Person('John Doe', 'john@example.com')) e1 = p.add_episode() e1.id('http://lernfunk.de/_MEDIAID_123#1') diff --git a/feedgen/feed.py b/feedgen/feed.py index 85a435c..c1766e7 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -34,7 +34,7 @@ class Podcast(object): * :attr:`~feedgen.podcast.Podcast.name` * :attr:`~feedgen.podcast.Podcast.website` * :attr:`~feedgen.podcast.Podcast.description` - * :attr:`~feedgen.podcast.Podcast.itunes_explicit` + * :attr:`~feedgen.podcast.Podcast.explicit` """ @@ -47,35 +47,35 @@ def __init__(self): ## RSS # http://www.rssboard.org/rss-specification # Mandatory: - self.__rss_title = None - self.__rss_link = None - self.__rss_description = None + self.__name = None + self.__website = None + self.__description = None + self.__explicit = None # Optional: - self.__rss_cloud = None - self.__rss_copyright = None - self.__rss_docs = 'http://www.rssboard.org/rss-specification' - self.__rss_generator = self._feedgen_generator_str - self.__rss_language = None - self.__rss_lastBuildDate = None - self.__rss_authors = [] - self.__rss_pubDate = None - self.__rss_skipHours = None - self.__rss_skipDays = None - self.__rss_webMaster = None - - self.__self_link = None + self.__cloud = None + self.__copyright = None + self.__docs = 'http://www.rssboard.org/rss-specification' + self.__generator = self._feedgen_generator_str + self.__language = None + self.__last_updated = None + self.__authors = [] + self.__publication_date = None + self.__skip_hours = None + self.__skip_days = None + self.__web_master = None + + self.__feed_url = None ## ITunes tags # http://www.apple.com/itunes/podcasts/specs.html#rss - 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.__withhold_from_itunes = None + self.__category = None + self.__image = None + self.__complete = None + self.__new_feed_url = None + self.__owner = None + self.__subtitle = None @property def episodes(self): @@ -206,63 +206,63 @@ def _create_rss(self): 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 - and self.__itunes_explicit is not None): - missing = ', '.join(([] if self.__rss_title else ['title']) + - ([] if self.__rss_link else ['link']) + - ([] if self.__rss_description else ['description']) + - ([] if self.__itunes_explicit else ['itunes_explicit'])) + if not (self.__name and self.__website and self.__description + and self.__explicit is not None): + missing = ', '.join(([] if self.__name else ['title']) + + ([] if self.__website else ['link']) + + ([] if self.__description else ['description']) + + ([] if self.__explicit else ['itunes_explicit'])) raise ValueError('Required fields not set (%s)' % missing) title = etree.SubElement(channel, 'title') - title.text = self.__rss_title + title.text = self.__name link = etree.SubElement(channel, 'link') - link.text = self.__rss_link + link.text = self.__website desc = etree.SubElement(channel, 'description') - desc.text = self.__rss_description + desc.text = self.__description explicit = etree.SubElement(channel, '{%s}explicit' % ITUNES_NS) - explicit.text = "yes" if self.__itunes_explicit else "no" + explicit.text = "yes" if self.__explicit else "no" - if self.__rss_cloud: + if self.__cloud: cloud = etree.SubElement(channel, 'cloud') - cloud.attrib['domain'] = self.__rss_cloud.get('domain') - cloud.attrib['port'] = str(self.__rss_cloud.get('port')) - cloud.attrib['path'] = self.__rss_cloud.get('path') - cloud.attrib['registerProcedure'] = self.__rss_cloud.get( + cloud.attrib['domain'] = self.__cloud.get('domain') + cloud.attrib['port'] = str(self.__cloud.get('port')) + cloud.attrib['path'] = self.__cloud.get('path') + cloud.attrib['registerProcedure'] = self.__cloud.get( 'registerProcedure') - cloud.attrib['protocol'] = self.__rss_cloud.get('protocol') - if self.__rss_copyright: + cloud.attrib['protocol'] = self.__cloud.get('protocol') + if self.__copyright: copyright = etree.SubElement(channel, 'copyright') - copyright.text = self.__rss_copyright - if self.__rss_docs: + copyright.text = self.__copyright + if self.__docs: docs = etree.SubElement(channel, 'docs') - docs.text = self.__rss_docs - if self.__rss_generator: + docs.text = self.__docs + if self.__generator: generator = etree.SubElement(channel, 'generator') - generator.text = self.__rss_generator - if self.__rss_language: + generator.text = self.__generator + if self.__language: language = etree.SubElement(channel, 'language') - language.text = self.__rss_language + language.text = self.__language - if self.__rss_lastBuildDate is None: + if self.__last_updated is None: lastBuildDateDate = datetime.now(dateutil.tz.tzutc()) else: - lastBuildDateDate = self.__rss_lastBuildDate + lastBuildDateDate = self.__last_updated if lastBuildDateDate: lastBuildDate = etree.SubElement(channel, 'lastBuildDate') lastBuildDate.text = formatRFC2822(lastBuildDateDate) - if self.__rss_authors: - authors_with_name = [a.name for a in self.__rss_authors if a.name] + if self.__authors: + authors_with_name = [a.name for a in self.__authors if a.name] if authors_with_name: # We have something to display as itunes:author, combine all # names itunes_author = \ etree.SubElement(channel, '{%s}author' % ITUNES_NS) itunes_author.text = listToHumanreadableStr(authors_with_name) - if len(self.__rss_authors) > 1 or not self.__rss_authors[0].email: + if len(self.__authors) > 1 or not self.__authors[0].email: # Use dc:creator, since it supports multiple authors (and # author without email) - for a in self.__rss_authors or []: + for a in self.__authors or []: author = etree.SubElement(channel, '{%s}creator' % nsmap['dc']) if a.name and a.email: @@ -274,75 +274,76 @@ def _create_rss(self): else: # Only one author and with email, so use rss managingEditor author = etree.SubElement(channel, 'managingEditor') - author.text = str(self.__rss_authors[0]) + author.text = str(self.__authors[0]) - if self.__rss_pubDate is None: - episode_dates = [e.published() for e in self.episodes if e.published() is not None] + if self.__publication_date is None: + episode_dates = [e.published() for e in self.episodes + if e.published() is not None] if episode_dates: actual_pubDate = max(episode_dates) else: actual_pubDate = None else: - actual_pubDate = self.__rss_pubDate + actual_pubDate = self.__publication_date if actual_pubDate: pubDate = etree.SubElement(channel, 'pubDate') pubDate.text = formatRFC2822(actual_pubDate) - if self.__rss_skipHours: + if self.__skip_hours: skipHours = etree.SubElement(channel, 'skipHours') - for h in self.__rss_skipHours: + for h in self.__skip_hours: hour = etree.SubElement(skipHours, 'hour') hour.text = str(h) - if self.__rss_skipDays: + if self.__skip_days: skipDays = etree.SubElement(channel, 'skipDays') - for d in self.__rss_skipDays: + for d in self.__skip_days: day = etree.SubElement(skipDays, 'day') day.text = d - if self.__rss_webMaster: - if not self.__rss_webMaster.email: + if self.__web_master: + if not self.__web_master.email: raise RuntimeError("webMaster must have an email. Did you " "set email to None after assigning that " "Person to webMaster?") webMaster = etree.SubElement(channel, 'webMaster') - webMaster.text = str(self.__rss_webMaster) + webMaster.text = str(self.__web_master) - if not self.__itunes_block is None: + if not self.__withhold_from_itunes is None: block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) - block.text = 'yes' if self.__itunes_block else 'no' + block.text = 'yes' if self.__withhold_from_itunes else 'no' - if self.__itunes_category: + if self.__category: category = etree.SubElement(channel, '{%s}category' % ITUNES_NS) - category.attrib['text'] = self.__itunes_category.category - if self.__itunes_category.subcategory: + category.attrib['text'] = self.__category.category + if self.__category.subcategory: subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) - subcategory.attrib['text'] = self.__itunes_category.subcategory + subcategory.attrib['text'] = self.__category.subcategory - if self.__itunes_image: + if self.__image: image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) - image.attrib['href'] = self.__itunes_image + image.attrib['href'] = self.__image - if self.__itunes_complete in ('yes', 'no'): + if self.__complete in ('yes', 'no'): complete = etree.SubElement(channel, '{%s}complete' % ITUNES_NS) - complete.text = self.__itunes_complete + complete.text = self.__complete - if self.__itunes_new_feed_url: + if self.__new_feed_url: new_feed_url = etree.SubElement(channel, '{%s}new-feed-url' % ITUNES_NS) - new_feed_url.text = self.__itunes_new_feed_url + new_feed_url.text = self.__new_feed_url - if self.__itunes_owner: + if self.__owner: owner = etree.SubElement(channel, '{%s}owner' % ITUNES_NS) owner_name = etree.SubElement(owner, '{%s}name' % ITUNES_NS) - owner_name.text = self.__itunes_owner.name + owner_name.text = self.__owner.name owner_email = etree.SubElement(owner, '{%s}email' % ITUNES_NS) - owner_email.text = self.__itunes_owner.email + owner_email.text = self.__owner.email - if self.__itunes_subtitle: + if self.__subtitle: subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) - subtitle.text = self.__itunes_subtitle + subtitle.text = self.__subtitle - if self.__self_link: + if self.__feed_url: link_to_self = etree.SubElement(channel, '{%s}link' % nsmap['atom']) - link_to_self.attrib['href'] = self.__self_link + link_to_self.attrib['href'] = self.__feed_url link_to_self.attrib['rel'] = 'self' link_to_self.attrib['type'] = 'application/rss+xml' @@ -407,18 +408,22 @@ def name(self, name=None): associated website. This is mandatory for RSS and must not be blank. + This will set rss:title. + :param name: The new name of the podcast. :type name: str :returns: The podcast's name. """ if not name is None: - self.__rss_title = name - return self.__rss_title + self.__name = name + return self.__name - def updated(self, updated=None): + def last_updated(self, last_updated=None): """Set or get the updated value which indicates the last time the feed - was modified in a significant way. + was modified in a significant way. Most often, it is taken to mean the + last time the feed was generated, which is why it defaults to the + time and date at which the RSS is generated, if set to None. 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 @@ -431,23 +436,23 @@ def updated(self, updated=None): Set this to False to have no updated value in the feed. - :param updated: The modification date. - :type updated: str or datetime.datetime + :param last_updated: The modification date. + :type last_updated: str or datetime.datetime :returns: Modification date as datetime.datetime """ - if not updated is None: - if updated is False: - self.__rss_lastBuildDate = False + if not last_updated is None: + if last_updated is False: + self.__last_updated = False else: - if isinstance(updated, string_types): - updated = dateutil.parser.parse(updated) - if not isinstance(updated, datetime): + if isinstance(last_updated, string_types): + last_updated = dateutil.parser.parse(last_updated) + if not isinstance(last_updated, datetime): raise ValueError('Invalid datetime format') - if updated.tzinfo is None: + if last_updated.tzinfo is None: raise ValueError('Datetime object has no timezone info') - self.__rss_lastBuildDate = updated + self.__last_updated = last_updated - return self.__rss_lastBuildDate + return self.__last_updated def website(self, href=None): @@ -459,19 +464,19 @@ def website(self, href=None): Example:: - >>> feedgen.website( href='http://example.com/') + >>> p.website( href='http://example.com/') """ if not href is None: - self.__rss_link = href - return self.__rss_link + self.__website = href + return self.__website 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. + """Set or get the cloud data of the feed. 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. @@ -485,15 +490,15 @@ def cloud(self, domain=None, port=None, path=None, registerProcedure=None, and protocol): raise ValueError("All parameters of cloud must be present and" " not empty.") - self.__rss_cloud = {'domain':domain, 'port':port, 'path':path, + self.__cloud = {'domain':domain, 'port':port, 'path':path, 'registerProcedure':registerProcedure, 'protocol':protocol} - return self.__rss_cloud + return self.__cloud def generator(self, generator=None, version=None, uri=None, exclude_feedgen=False): - """Get or the generator of the feed which identifies the software used to - generate the feed, for debugging and other purposes. + """Get or set the generator of the feed, which identifies the software + used to generate the feed, for debugging and other purposes. :param generator: Software used to create the feed. :param version: (Optional) Version of the software, as a tuple. @@ -502,10 +507,10 @@ def generator(self, generator=None, version=None, uri=None, of the python-feedgen library. """ if not generator is None: - self.__rss_generator = self._program_name_to_str(generator, version, uri) + \ - (" (using %s)" % self._feedgen_generator_str + self.__generator = self._program_name_to_str(generator, version, uri) + \ + (" (using %s)" % self._feedgen_generator_str if not exclude_feedgen else "") - return self.__rss_generator + return self.__generator def _program_name_to_str(self, generator=None, version=None, uri=None): return generator + \ @@ -539,8 +544,8 @@ def copyright(self, copyright=None): """ if not copyright is None: - self.__rss_copyright = copyright - return self.__rss_copyright + self.__copyright = copyright + return self.__copyright def description(self, description=None): @@ -554,8 +559,8 @@ def description(self, description=None): """ if not description is None: - self.__rss_description = description - return self.__rss_description + self.__description = description + return self.__description def language(self, language=None): @@ -572,8 +577,8 @@ def language(self, language=None): :returns: Language of the feed. """ if not language is None: - self.__rss_language = language - return self.__rss_language + self.__language = language + return self.__language @property @@ -606,19 +611,19 @@ def authors(self): >>> p.authors = [Person("John Doe", "johndoe@example.org"), ... Person("Mary Sue", "marysue@example.org")] """ - return self.__rss_authors + return self.__authors @authors.setter def authors(self, authors): try: - self.__rss_authors = list(authors) + self.__authors = list(authors) except TypeError: raise TypeError("Only iterable types can be assigned to authors, " "%s given. You must put your object in a list, " "even if there's only one author." % authors) - def published(self, pubDate=None): + def publication_date(self, publication_date=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 @@ -636,22 +641,22 @@ def published(self, pubDate=None): If you want to omit the publication date from the feed, set pubDate to False. - :param pubDate: The publication date. + :param publication_date: 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 pubDate is not False and not isinstance(pubDate, datetime): + if not publication_date is None: + if isinstance(publication_date, string_types): + publication_date = dateutil.parser.parse(publication_date) + if publication_date is not False and not isinstance(publication_date, datetime): raise ValueError('Invalid datetime format') - elif pubDate is not False and pubDate.tzinfo is None: + elif publication_date is not False and publication_date.tzinfo is None: raise ValueError('Datetime object has no timezone info') - self.__rss_pubDate = pubDate + self.__publication_date = publication_date - return self.__rss_pubDate + return self.__publication_date - def skipHours(self, hours=None, replace=False): + def skip_hours(self, hours=None, replace=False): """Set or get which hours feed readers don't need to refresh this feed. This method can be called with an hour or a list of hours. The hours are @@ -663,9 +668,9 @@ def skipHours(self, hours=None, replace=False): >>> from feedgen.feed import Podcast >>> p = Podcast() - >>> p.skipHours(range(18, 24)) + >>> p.skip_hours(range(18, 24)) {18, 19, 20, 21, 22, 23} - >>> p.skipHours(range(8)) + >>> p.skip_hours(range(8)) {0, 1, 2, 3, 4, 5, 6, 7, 18, 19, 20, 21, 22, 23} :param hours: List of hours the feedreaders should not check the feed. @@ -679,13 +684,13 @@ def skipHours(self, hours=None, replace=False): 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 + if replace or not self.__skip_hours: + self.__skip_hours = set() + self.__skip_hours |= set(hours) + return self.__skip_hours - def skipDays(self, days=None, replace=False): + def skip_days(self, days=None, replace=False): """Set or get the value of skipDays, a hint for aggregators telling them which days they can skip. @@ -704,39 +709,39 @@ def skipDays(self, days=None, replace=False): if not d 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 + if replace or not self.__skip_days: + self.__skip_days = set() + self.__skip_days |= set(days) + return self.__skip_days - def webMaster(self, webMaster=None): + def web_master(self, web_master=None): """Get and set the person responsible for technical issues relating to the feed. - :param webMaster: The person responsible for technical issues relating + :param web_master: The person responsible for technical issues relating to the feed. This instance of Person must have its email set. - :type webMaster: Person + :type web_master: Person :returns: The person responsible for technical issues relating to the feed. """ - if webMaster is not None: - if (not hasattr(webMaster, "email")) or not webMaster.email: + if web_master is not None: + if (not hasattr(web_master, "email")) or not web_master.email: raise ValueError("The webmaster must have an email attribute " "and it must be set and not empty.") - self.__rss_webMaster = webMaster - return self.__rss_webMaster + self.__web_master = web_master + return self.__web_master - def itunes_block(self, itunes_block=None): + def withhold_from_itunes(self, withhold_from_itunes=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. + :param withhold_from_itunes: Block the podcast. :returns: If the podcast is blocked. """ - if not itunes_block is None: - self.__itunes_block = itunes_block - return self.__itunes_block + if not withhold_from_itunes is None: + self.__withhold_from_itunes = withhold_from_itunes + return self.__withhold_from_itunes def category(self, category=None): """Get or set the iTunes category, which appears in the category column @@ -752,14 +757,14 @@ def category(self, category=None): # Check that the category quacks like a duck if hasattr(category, "category") and \ hasattr(category, "subcategory"): - self.__itunes_category = category + self.__category = category else: raise TypeError("A Category(-like) object must be used, got " "%s" % category) - return self.__itunes_category + return self.__category - def itunes_image(self, itunes_image=None): + def image(self, 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 @@ -777,22 +782,24 @@ def itunes_image(self, itunes_image=None): 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. - :type itunes_image: str + :param image: Image of the podcast. + :type image: str :returns: Image of the podcast. """ - if not itunes_image is None: - lowercase_itunes_image = itunes_image.lower() + if not image is None: + lowercase_itunes_image = image.lower() if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) - self.__itunes_image = itunes_image - return self.__itunes_image + raise ValueError('Image filename must end with png or jpg, not .%s' % image.split(".")[-1]) + self.__image = image + return self.__image - def itunes_explicit(self, itunes_explicit=None): + def explicit(self, explicit=None): """Get or set whether this podcast may be inappropriate for children or not. - This is one of the mandatory attributes. + This is one of the mandatory attributes, and can seen as the + default for episodes. Individual episodes can be marked as explicit + or clean independently from the podcast. If you set this to ``True``, an "explicit" parental advisory graphic will appear next to your podcast artwork on the iTunes Store and @@ -801,83 +808,105 @@ def itunes_explicit(self, itunes_explicit=None): language or adult content is included anywhere in the episodes, and a "clean" graphic will appear. - :param itunes_explicit: True if explicit, False if not. - :type itunes_explicit: bool or None + :param explicit: True if explicit, False if not. + :type explicit: bool or None :returns: Whether the podcast contains explicit material or not. """ - if not itunes_explicit is None: - self.__itunes_explicit = itunes_explicit - return self.__itunes_explicit + if not explicit is None: + self.__explicit = explicit + return self.__explicit - def itunes_complete(self, itunes_complete=None): + def complete(self, 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 <itunes:complete> tag is + 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. - :type itunes_complete: bool or str + .. warning:: + + Setting this to ``True`` is the same as promising you'll never ever + release a new episode. Do NOT set this to ``True`` as long as + there's any chance at all that a new episode will be released + someday. + + :param complete: If the podcast is complete. + :type complete: bool or str :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 "%s" for complete tag' % itunes_complete) - 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 + if not complete is None: + if not complete in ('yes', 'no', '', True, False): + raise ValueError('Invalid value "%s" for complete tag' % complete) + if complete == True: + complete = 'yes' + if complete == False: + complete = 'no' + self.__complete = complete + return self.__complete + + def new_feed_url(self, new_feed_url=None): + """Get or set the itunes-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. - :type itunes_new_feed_url: str + .. warning:: + + iTunes supports this mechanic of changing your feed's location. + However, you cannot assume the same of everyone else who has + subscribed to this podcast. Therefore, you should NEVER stop + supporting an old location for your podcast. Instead, you should + create redirects so those with the old address are redirected to + your new address, and keep those up for all eternity. + + .. warning:: + + Make sure the new URL here is correct, or else you're making + people switch to a URL that doesn't work! + + :param new_feed_url: New feed URL. + :type new_feed_url: str :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 + if not new_feed_url is None: + self.__new_feed_url = new_feed_url + return self.__new_feed_url - def itunes_owner(self, owner): - """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 + def owner(self, owner): + """Get or set the owner of the podcast. This tag contains + information that iTunes will use to contact the owner of the podcast for communication specifically about the podcast. It will not be publicly - displayed. + displayed, but it will be in the feed source. - Both the name and email are required; you cannot use one or the other alone. + Both the name and email are required. - :param owner: The person which iTunes will contact when needed. + :param owner: The :class:`~feedgen.person.Person` which iTunes will + contact when needed. :returns: The owner of this feed, which iTunes will contact when needed. """ if owner is not None: if owner.name and owner.email: - self.__itunes_owner = owner + self.__owner = owner else: raise ValueError('Both name and email must be set.') - return self.__itunes_owner + return self.__owner - def itunes_subtitle(self, itunes_subtitle=None): + def subtitle(self, 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. - :type itunes_subtitle: str + :param subtitle: Subtitle of the podcast. + :type subtitle: str :returns: Subtitle of the podcast. """ - if not itunes_subtitle is None: - self.__itunes_subtitle = itunes_subtitle - return self.__itunes_subtitle + if not subtitle is None: + self.__subtitle = subtitle + return self.__subtitle def feed_url(self, feed_url=None): """Get or set the URL which this feed is available at. @@ -899,6 +928,6 @@ def feed_url(self, feed_url=None): raise ValueError("The feed url must be a valid URL, but it " "doesn't have a valid URL scheme " "(like for example http:// or https://)") - self.__self_link = feed_url - return self.__self_link + self.__feed_url = feed_url + return self.__feed_url diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 01ae3f4..1780c8f 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -32,7 +32,7 @@ def setUp(self): fg.name(self.title) fg.website(self.link) fg.description(self.description) - fg.itunes_explicit(self.explicit) + fg.explicit(self.explicit) fe = fg.add_episode() fe.id('http://lernfunk.de/media/654321/1') @@ -140,7 +140,7 @@ def test_feedPubDateNotOverriddenByEpisode(self): assert parsedate(pubDate.text) == self.fg.episodes[0].published() new_date = datetime.datetime(2016, 1, 2, 3, 4, tzinfo=pytz.utc) - self.fg.published(new_date) + self.fg.publication_date(new_date) pubDate = self.fg._create_rss().find("channel").find("pubDate") # Now it uses the custom-set date assert pubDate is not None @@ -150,7 +150,7 @@ def test_feedPubDateDisabled(self): self.fg.episodes[0].published( datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) ) - self.fg.published(False) + self.fg.publication_date(False) pubDate = self.fg._create_rss().find("channel").find("pubDate") assert pubDate is None # Not found! diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index ab64bfd..a10a635 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -65,11 +65,11 @@ def setUp(self): protocol=self.cloudProtocol) fg.copyright(self.copyright) fg.authors.append(self.author) - fg.skipDays(self.skipDays) - fg.skipHours(self.skipHours) - fg.webMaster(self.webMaster) + fg.skip_days(self.skipDays) + fg.skip_hours(self.skipHours) + fg.web_master(self.webMaster) fg.feed_url(self.feedUrl) - fg.itunes_explicit(self.explicit) + fg.explicit(self.explicit) self.fg = fg @@ -80,7 +80,7 @@ def test_baseFeed(self): assert fg.name() == self.title assert fg.authors[0] == self.author - assert fg.webMaster() == self.webMaster + assert fg.web_master() == self.webMaster assert fg.website() == self.linkHref @@ -169,13 +169,13 @@ def getLastBuildDateElement(fg): assert getLastBuildDateElement(self.fg) is not None # Test that it respects my custom value - self.fg.updated(date) + self.fg.last_updated(date) lastBuildDate = getLastBuildDateElement(self.fg) assert lastBuildDate is not None assert dateutil.parser.parse(lastBuildDate.text) == date # Test that it is left out when set to False - self.fg.updated(False) + self.fg.last_updated(False) lastBuildDate = getLastBuildDateElement(self.fg) assert lastBuildDate is None @@ -259,18 +259,18 @@ def do_authorsInvalidValue(self): def test_webMaster(self): - self.fg.webMaster(Person(None, "justan@email.address")) + self.fg.web_master(Person(None, "justan@email.address")) channel = self.fg._create_rss().find("channel") - assert channel.find("webMaster").text == self.fg.webMaster().email + assert channel.find("webMaster").text == self.fg.web_master().email - self.assertRaises(ValueError, self.fg.webMaster, + self.assertRaises(ValueError, self.fg.web_master, Person("Mr. No Email Address")) - self.fg.webMaster(Person("Both a name", "and_an@email.com")) + self.fg.web_master(Person("Both a name", "and_an@email.com")) channel = self.fg._create_rss().find("channel") # Does webMaster follow the pattern "email (name)"? - self.assertEqual(self.fg.webMaster().email + - " (" + self.fg.webMaster().name + ")", + self.assertEqual(self.fg.web_master().email + + " (" + self.fg.web_master().name + ")", channel.find("webMaster").text) def test_categoryWithoutSubcategory(self): @@ -300,7 +300,7 @@ def test_categoryChecks(self): self.assertRaises(TypeError, self.fg.category, c) def test_explicitIsExplicit(self): - self.fg.itunes_explicit(True) + self.fg.explicit(True) channel = self.fg._create_rss().find("channel") itunes_explicit = channel.find("{%s}explicit" % self.nsItunes) assert itunes_explicit is not None @@ -309,7 +309,7 @@ def test_explicitIsExplicit(self): % itunes_explicit.text def test_explicitIsClean(self): - self.fg.itunes_explicit(False) + self.fg.explicit(False) channel = self.fg._create_rss().find("channel") itunes_explicit = channel.find("{%s}explicit" % self.nsItunes) assert itunes_explicit is not None @@ -325,7 +325,7 @@ def test_mandatoryValues(self): "description", "title", "link", - "itunes_explicit", + "explicit", } for test_property in mandatory_properties: @@ -336,8 +336,8 @@ def test_mandatoryValues(self): fg.name(self.title) if test_property != "link": fg.website(self.linkHref) - if test_property != "itunes_explicit": - fg.itunes_explicit(self.explicit) + if test_property != "explicit": + fg.explicit(self.explicit) try: self.assertRaises(ValueError, fg._create_rss) except AssertionError as e: From ccec8e98d10165c91ebbecd3de4e4ce4987100e6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 22:36:50 +0200 Subject: [PATCH 070/200] Refactor BaseEpisode's API so attributes have obvious names --- doc/user/basic_usage_guide/part_2.rst | 12 +- feedgen/__main__.py | 6 +- feedgen/feed.py | 4 +- feedgen/item.py | 314 +++++++++++++------------- feedgen/tests/test_entry.py | 26 +-- 5 files changed, 185 insertions(+), 177 deletions(-) diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index ded6384..0f9ed30 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -132,10 +132,10 @@ will get a new episode which appears to have existed for longer than it has. :: - my_episode.published_date = datetime.datetime(2016, 5, 18, 10, 0, + my_episode.publication_date = datetime.datetime(2016, 5, 18, 10, 0, tzinfo=pytz.utc) -.. autosummary:: ~feedgen.item.BaseEpisode.published_date +.. autosummary:: ~feedgen.item.BaseEpisode.publication_date The Link @@ -191,8 +191,8 @@ Less used attributes my_episode.image = "http://example.com/static/best-example.png" my_episode.explicit = False - my_episode.is_close_captioned = False # Only applicable for video - my_episode.order = 1 + my_episode.is_closed_captioned = False # Only applicable for video + my_episode.position = 1 # Be careful about using the following attribute! my_episode.withhold_from_itunes = True @@ -200,8 +200,8 @@ Less used attributes ~feedgen.item.BaseEpisode.image ~feedgen.item.BaseEpisode.explicit - ~feedgen.item.BaseEpisode.is_close_captioned - ~feedgen.item.BaseEpisode.order + ~feedgen.item.BaseEpisode.is_closed_captioned + ~feedgen.item.BaseEpisode.position ~feedgen.item.BaseEpisode.withhold_from_itunes The final step is :doc:`part_3` diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 80cf457..06d8d25 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -70,10 +70,10 @@ def main(): domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, verba <3.''', html=False) - e1.link(href='http://example.com') + e1.link(link='http://example.com') e1.authors = [Person('Lars Kiesow', 'lkiesow@uos.de')] - e1.published(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) - e1.enclosure(Media("http://example.com/episodes/loremipsum.mp3", 454599964)) + e1.publication_date(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) + e1.media(Media("http://example.com/episodes/loremipsum.mp3", 454599964)) # Should we just print out, or write to file? if arg == 'rss': diff --git a/feedgen/feed.py b/feedgen/feed.py index c1766e7..029aeb4 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -277,8 +277,8 @@ def _create_rss(self): author.text = str(self.__authors[0]) if self.__publication_date is None: - episode_dates = [e.published() for e in self.episodes - if e.published() is not None] + episode_dates = [e.publication_date() for e in self.episodes + if e.publication_date() is not None] if episode_dates: actual_pubDate = max(episode_dates) else: diff --git a/feedgen/item.py b/feedgen/item.py index 5fc647d..7dbbad9 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -29,26 +29,25 @@ class BaseEpisode(object): def __init__(self): # RSS - self.__rss_authors = [] - self.__rss_content = None - self.__rss_enclosure = None - self.__rss_guid = None - self.__rss_link = None - self.__rss_pubDate = None - self.__rss_title = None + self.__authors = [] + self.__summary = None + self.__media = None + self.__id = None + self.__rss_link = None + self.__publication_date = None + self.__title = None # ITunes tags # http://www.apple.com/itunes/podcasts/specs.html#rss - self.__itunes_block = None - self.__itunes_image = None + self.__withhold_from_itunes = None + self.__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.__explicit = None + self.__is_closed_captioned = None + self.__position = None + self.__subtitle = None - - def rss_entry(self, extensions=True): + def rss_entry(self): """Create a RSS item and return it.""" ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' @@ -56,36 +55,36 @@ def rss_entry(self, extensions=True): entry = etree.Element('item') - if not ( self.__rss_title or self.__rss_content): + if not (self.__title or self.__summary): raise ValueError('Required fields not set') - if self.__rss_title: + if self.__title: title = etree.SubElement(entry, 'title') - title.text = self.__rss_title + title.text = self.__title if self.__rss_link: link = etree.SubElement(entry, 'link') link.text = self.__rss_link - if self.__rss_content: + if self.__summary: description = etree.SubElement(entry, 'description') - description.text = etree.CDATA(self.__rss_content) + description.text = etree.CDATA(self.__summary) content = etree.SubElement(entry, '{%s}encoded' % 'http://purl.org/rss/1.0/modules/content/') - content.text = etree.CDATA(self.__rss_content) + content.text = etree.CDATA(self.__summary) - if self.__rss_authors: - authors_with_name = [a.name for a in self.__rss_authors if a.name] + if self.__authors: + authors_with_name = [a.name for a in self.__authors if a.name] if authors_with_name: # We have something to display as itunes:author, combine all # names itunes_author = \ etree.SubElement(entry, '{%s}author' % ITUNES_NS) itunes_author.text = listToHumanreadableStr(authors_with_name) - if len(self.__rss_authors) > 1 or not self.__rss_authors[0].email: + if len(self.__authors) > 1 or not self.__authors[0].email: # Use dc:creator, since it supports multiple authors (and # author without email) - for a in self.__rss_authors or []: + for a in self.__authors or []: author = etree.SubElement(entry, '{%s}creator' % DUBLIN_NS) if a.name and a.email: author.text = "%s <%s>" % (a.name, a.email) @@ -96,12 +95,12 @@ def rss_entry(self, extensions=True): else: # Only one author and with email, so use rss author author = etree.SubElement(entry, 'author') - author.text = str(self.__rss_authors[0]) + author.text = str(self.__authors[0]) - if self.__rss_guid: - rss_guid = self.__rss_guid - elif self.__rss_enclosure and self.__rss_guid is None: - rss_guid = self.__rss_enclosure.url + if self.__id: + rss_guid = self.__id + elif self.__media and self.__id is None: + rss_guid = self.__media.url else: # self.__rss_guid was set to boolean False, or no enclosure rss_guid = None @@ -110,59 +109,56 @@ def rss_entry(self, extensions=True): guid.text = rss_guid guid.attrib['isPermaLink'] = 'false' - if self.__rss_enclosure: + if self.__media: enclosure = etree.SubElement(entry, 'enclosure') - enclosure.attrib['url'] = self.__rss_enclosure.url - enclosure.attrib['length'] = str(self.__rss_enclosure.size) - enclosure.attrib['type'] = self.__rss_enclosure.type + enclosure.attrib['url'] = self.__media.url + enclosure.attrib['length'] = str(self.__media.size) + enclosure.attrib['type'] = self.__media.type - if self.__rss_pubDate: + if self.__publication_date: pubDate = etree.SubElement(entry, 'pubDate') - pubDate.text = formatRFC2822(self.__rss_pubDate) + pubDate.text = formatRFC2822(self.__publication_date) - if not self.__itunes_block is None: + if not self.__withhold_from_itunes is None: block = etree.SubElement(entry, '{%s}block' % ITUNES_NS) - block.text = 'yes' if self.__itunes_block else 'no' + block.text = 'yes' if self.__withhold_from_itunes else 'no' - if self.__itunes_image: + if self.__image: image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) - image.attrib['href'] = self.__itunes_image + image.attrib['href'] = self.__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'): + if self.__explicit in ('yes', 'no', 'clean'): explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) - explicit.text = self.__itunes_explicit + explicit.text = self.__explicit - if not self.__itunes_is_closed_captioned is None: + if not self.__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' + is_closed_captioned.text = 'yes' if self.__is_closed_captioned else 'no' - if not self.__itunes_order is None and self.__itunes_order >= 0: + if not self.__position is None and self.__position >= 0: order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) - order.text = str(self.__itunes_order) + order.text = str(self.__position) - if self.__itunes_subtitle: + if self.__subtitle: subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) - subtitle.text = self.__itunes_subtitle + subtitle.text = self.__subtitle 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 and should not be blank. + """Get or set this episode's human-readable title. + Title is mandatory and should not be blank. - :param title: The new title of the entry. - :returns: The entriess title. + :param title: This new title of this episode. + :returns: This episode's title. """ if not title is None: - self.__rss_title = title - return self.__rss_title - + self.__title = title + return self.__title def id(self, new_id=None): """Get or set this episode's globally unique identifier. @@ -191,9 +187,8 @@ def id(self, new_id=None): :returns: Id of this episode. """ if not new_id is None: - self.__rss_guid = new_id - return self.__rss_guid - + self.__id = new_id + return self.__id @property def authors(self): @@ -230,12 +225,12 @@ def authors(self): >>> ep.authors = [Person("John Doe", "johndoe@example.org"), ... Person("Mary Sue", "marysue@example.org")] """ - return self.__rss_authors + return self.__authors @authors.setter def authors(self, authors): try: - self.__rss_authors = list(authors) + self.__authors = list(authors) except TypeError: raise TypeError("Only iterable types can be assigned to authors, " "%s given. You must put your object in a list, " @@ -261,45 +256,43 @@ def summary(self, new_summary=None, html=True): if not new_summary is None: if not html: new_summary = htmlencode(new_summary) - self.__rss_content = new_summary - return self.__rss_content - + self.__summary = new_summary + return self.__summary - def link(self, href=None): + def link(self, link=None): """Get or set the link to the full version of this episode description. - :param href: the URI of the referenced resource (typically a Web page) + :param link: the URI of the referenced resource (typically a Web page) + :type link: str :returns: The current link URI. """ - if not href is None: - self.__rss_link = href + if not link is None: + self.__rss_link = link return self.__rss_link - - 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. + def publication_date(self, publication_date=None): + """Set or get the time that this episode first was made public. 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. + datetime.datetime object. In both cases you must ensure that the value + includes timezone information. - :param published: The creation date. + :param publication_date: The date this episode was first made public. + :type publication_date: datetime.datetime or str or None :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): + if not publication_date is None: + if isinstance(publication_date, string_types): + publication_date = dateutil.parser.parse(publication_date) + if not isinstance(publication_date, datetime): raise ValueError('Invalid datetime format') - if published.tzinfo is None: + if publication_date.tzinfo is None: raise ValueError('Datetime object has no timezone info') - self.__rss_pubDate = published - - return self.__rss_pubDate + self.__publication_date = publication_date + return self.__publication_date - def enclosure(self, media=None): + def media(self, media=None): """Get or set the :class:`~feedgen.media.Media` object that is attached to this episode. @@ -321,26 +314,34 @@ def enclosure(self, media=None): if hasattr(media, "url") and hasattr(media, "size") and \ hasattr(media, "type"): # It's a duck - self.__rss_enclosure = media + self.__media = media else: raise TypeError("The parameter media must have the attributes " "url, size and type.") - return self.__rss_enclosure + return self.__media - def itunes_block(self, itunes_block=None): + def withhold_from_itunes(self, withhold_from_itunes=None): """Get or set the ITunes block attribute. Use this to prevent episodes - from appearing in the iTunes podcast directory. Note that the episode can still be - found by inspecting the XML, thus it is public. + from appearing in the iTunes podcast directory. Note that the episode + can still be found by inspecting the XML, so it is still public. + + One use case is if you know that this episode will get you kicked + out from iTunes, should it make it there. In such cases, you can set + withhold_from_itunes to ``True`` so this episode isn't published on + iTunes, allowing you to publish it to everyone else while keeping your + podcast on iTunes. - :param itunes_block: Block podcast episodes. - :type itunes_block: bool - :returns: If the podcast episode is blocked. + This attribute defaults to ``False``, of course. + + :param withhold_from_itunes: Block podcast episode from iTunes. + :type withhold_from_itunes: bool + :returns: Whether the podcast episode is withheld from iTunes or not. """ - if not itunes_block is None: - self.__itunes_block = itunes_block - return self.__itunes_block + if not withhold_from_itunes is None: + self.__withhold_from_itunes = withhold_from_itunes + return self.__withhold_from_itunes - def itunes_image(self, itunes_image=None): + def image(self, 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, @@ -357,17 +358,21 @@ def itunes_image(self, itunes_image=None): 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. - :type itunes_image: str - :returns: Image of the podcast. + + Oh, and iTunes doesn't support this. You need to embed the image inside + the media file as well (like regular album covers). The Podcast.image + attribute is used if not. + + :param image: Image of the episode. + :type image: str + :returns: Image of the episode. """ - if not itunes_image is None: - lowercase_itunes_image = itunes_image.lower() - if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not .%s' % itunes_image.split(".")[-1]) - self.__itunes_image = itunes_image - return self.__itunes_image + if not image is None: + lowercase_image = image.lower() + if not (lowercase_image.endswith(('.jpg', '.jpeg', '.png'))): + raise ValueError('Image filename must end with png or jpg, not .%s' % image.split(".")[-1]) + self.__image = image + return self.__image def itunes_duration(self, itunes_duration=None): """Get or set the duration of the podcast episode. The content of this @@ -392,74 +397,77 @@ def itunes_duration(self, itunes_duration=None): self.__itunes_duration = itunes_duration return self.itunes_duration - def itunes_explicit(self, itunes_explicit=None): + def explicit(self, 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: "yes" if the podcast contains explicit material, "clean" if it doesn't. "no" counts - as blank. - :type itunes_explicit: str + material. + + The value of the podcast's explicit attribute is used by default. + + If you set this to ``True``, an "explicit" parental advisory + graphic will appear in the Name column in iTunes. If the value is + ``False``, the parental advisory type is considered Clean, meaning that + no explicit language or adult content is included anywhere in this + episode, and a "clean" graphic will appear. + + :param explicit: ``True`` if the podcast contains material + that may be inappropriate for children, ``False`` if it doesn't. + :type explicit: str :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 "%s" for explicit tag' % itunes_explicit) - self.__itunes_explicit = itunes_explicit - return self.__itunes_explicit + if not explicit is None: + if not explicit in ('', 'yes', 'no', 'clean'): + raise ValueError('Invalid value "%s" for explicit tag' % explicit) + self.__explicit = explicit + return self.__explicit - def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None): + def is_closed_captioned(self, 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”. + tag should be used if your podcast includes a video episode with + embedded closed captioning support. The two values for this tag are + ``True`` and ``False``. - :param itunes_is_closed_captioned: If the episode has closed captioning support. - :type itunes_is_closed_captioned: bool or str + :param is_closed_captioned: If the episode has closed captioning + support. + :type is_closed_captioned: bool or None :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 + if not is_closed_captioned is None: + self.__is_closed_captioned = is_closed_captioned + return self.__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. + def position(self, position=None): + """Get or set the itunes:order value of this 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). + multiple episodes share the same position, they will be sorted by their + publication date. - To remove the order from the episode set the order to a value below zero. + To remove the order from the episode set the position to a value below + zero. - :param itunes_order: The order of the episode. - :type itunes_order: int - :returns: The order of the episode. + :param position: This episode's desired position on the iTunes store + page. + :type position: int + :returns: This episode's desired position on the iTunes Store page. """ - if not itunes_order is None: - self.__itunes_order = int(itunes_order) - return self.__itunes_order + if not position is None: + self.__position = int(position) + return self.__position - def itunes_subtitle(self, itunes_subtitle=None): + def subtitle(self, 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. - :type itunes_subtitle: str + :param subtitle: Subtitle of the podcast episode. + :type subtitle: str :returns: Subtitle of the podcast episode. """ - if not itunes_subtitle is None: - self.__itunes_subtitle = itunes_subtitle - return self.__itunes_subtitle + if not subtitle is None: + self.__subtitle = subtitle + return self.__subtitle diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 1780c8f..cbd62a5 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -99,7 +99,7 @@ def test_idNotSetButEnclosureIsUsed(self): guid = "http://example.com/podcast/episode1.mp3" episode = self.fg.Episode() episode.title("My first episode") - episode.enclosure(Media(guid, 97423487, "audio/mpeg")) + episode.media(Media(guid, 97423487, "audio/mpeg")) item = episode.rss_entry() assert item.find("guid").text == guid @@ -107,37 +107,37 @@ def test_idNotSetButEnclosureIsUsed(self): def test_idSetToFalseSoEnclosureNotUsed(self): episode = self.fg.Episode() episode.title("My first episode") - episode.enclosure(Media("http://example.com/podcast/episode1.mp3", - 34328731, "audio/mpeg")) + episode.media(Media("http://example.com/podcast/episode1.mp3", + 34328731, "audio/mpeg")) episode.id(False) item = episode.rss_entry() assert item.find("guid") is None def test_feedPubDateUsesNewestEpisode(self): - self.fg.episodes[0].published( + self.fg.episodes[0].publication_date( datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) ) - self.fg.episodes[1].published( + self.fg.episodes[1].publication_date( datetime.datetime(2016, 1, 3, 12, 22, tzinfo=pytz.utc) ) - self.fg.episodes[2].published( + self.fg.episodes[2].publication_date( datetime.datetime(2014, 3, 2, 13, 11, tzinfo=pytz.utc) ) rss = self.fg._create_rss() pubDate = rss.find("channel").find("pubDate") assert pubDate is not None parsedPubDate = parsedate(pubDate.text) - assert parsedPubDate == self.fg.episodes[1].published() + assert parsedPubDate == self.fg.episodes[1].publication_date() def test_feedPubDateNotOverriddenByEpisode(self): - self.fg.episodes[0].published( + self.fg.episodes[0].publication_date( datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) ) pubDate = self.fg._create_rss().find("channel").find("pubDate") # Now it uses the episode's published date assert pubDate is not None - assert parsedate(pubDate.text) == self.fg.episodes[0].published() + assert parsedate(pubDate.text) == self.fg.episodes[0].publication_date() new_date = datetime.datetime(2016, 1, 2, 3, 4, tzinfo=pytz.utc) self.fg.publication_date(new_date) @@ -147,7 +147,7 @@ def test_feedPubDateNotOverriddenByEpisode(self): assert parsedate(pubDate.text) == new_date def test_feedPubDateDisabled(self): - self.fg.episodes[0].published( + self.fg.episodes[0].publication_date( datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) ) self.fg.publication_date(False) @@ -229,7 +229,7 @@ def do_authorsInvalidAssignment(self): def test_media(self): media = Media("http://example.org/episodes/1.mp3", 14536453, "audio/mpeg") - self.fe.enclosure(media) + self.fe.media(media) enclosure = self.fe.rss_entry().find("enclosure") self.assertEqual(enclosure.get("url"), media.url) @@ -237,6 +237,6 @@ def test_media(self): self.assertEqual(enclosure.get("type"), media.type) # Ensure duck-typing is checked at assignment time - self.assertRaises(TypeError, self.fe.enclosure, media.url) - self.assertRaises(TypeError, self.fe.enclosure, + self.assertRaises(TypeError, self.fe.media, media.url) + self.assertRaises(TypeError, self.fe.media, (media.url, media.size, media.type)) From 849f45638c5064f9e6a4e8264c35604dcdffe1d5 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 23:35:55 +0200 Subject: [PATCH 071/200] Make BaseEpisode.withhold_from_itunes accept bool, not str --- feedgen/item.py | 7 ++++--- feedgen/tests/test_entry.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/feedgen/item.py b/feedgen/item.py index 7dbbad9..596850f 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -39,7 +39,7 @@ def __init__(self): # ITunes tags # http://www.apple.com/itunes/podcasts/specs.html#rss - self.__withhold_from_itunes = None + self.__withhold_from_itunes = False self.__image = None self.__itunes_duration = None self.__explicit = None @@ -119,9 +119,10 @@ def rss_entry(self): pubDate = etree.SubElement(entry, 'pubDate') pubDate.text = formatRFC2822(self.__publication_date) - if not self.__withhold_from_itunes is None: + if self.__withhold_from_itunes: + # It is True, so include element - otherwise, don't include it block = etree.SubElement(entry, '{%s}block' % ITUNES_NS) - block.text = 'yes' if self.__withhold_from_itunes else 'no' + block.text = 'Yes' if self.__image: image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index cbd62a5..cdd1a8d 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -240,3 +240,16 @@ def test_media(self): self.assertRaises(TypeError, self.fe.media, media.url) self.assertRaises(TypeError, self.fe.media, (media.url, media.size, media.type)) + + def test_withholdFromItunesOffByDefault(self): + assert not self.fe.withhold_from_itunes() + + def test_withholdFromItunes(self): + self.fe.withhold_from_itunes(True) + itunes_block = self.fe.rss_entry().find("{%s}block" % self.itunes_ns) + assert itunes_block is not None + self.assertEqual(itunes_block.text.lower(), "yes") + + self.fe.withhold_from_itunes(False) + itunes_block = self.fe.rss_entry().find("{%s}block" % self.itunes_ns) + assert itunes_block is None From e8cb4a294993acf555d2432677ae4d263261020c Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 2 Jul 2016 23:51:29 +0200 Subject: [PATCH 072/200] Make Podcast.withhold_from_itunes accept bool, not str Make some small improvements to the documentation. --- feedgen/feed.py | 26 +++++++++++++++++++------- feedgen/item.py | 2 +- feedgen/tests/test_feed.py | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 029aeb4..0f6b3d4 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -69,7 +69,7 @@ def __init__(self): ## ITunes tags # http://www.apple.com/itunes/podcasts/specs.html#rss - self.__withhold_from_itunes = None + self.__withhold_from_itunes = False self.__category = None self.__image = None self.__complete = None @@ -307,9 +307,9 @@ def _create_rss(self): webMaster = etree.SubElement(channel, 'webMaster') webMaster.text = str(self.__web_master) - if not self.__withhold_from_itunes is None: + if self.__withhold_from_itunes: block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) - block.text = 'yes' if self.__withhold_from_itunes else 'no' + block.text = 'Yes' if self.__category: category = etree.SubElement(channel, '{%s}category' % ITUNES_NS) @@ -716,8 +716,8 @@ def skip_days(self, days=None, replace=False): def web_master(self, web_master=None): - """Get and set the person responsible for technical issues relating to - the feed. + """Get and set the :class:`~feedgen.person.Person` responsible for + technical issues relating to the feed. :param web_master: The person responsible for technical issues relating to the feed. This instance of Person must have its email set. @@ -733,10 +733,22 @@ def web_master(self, web_master=None): return self.__web_master def withhold_from_itunes(self, withhold_from_itunes=None): - """Get or set the ITunes block attribute. Use this to prevent the entire + """Get or set the iTunes block attribute. Use this to prevent the entire podcast from appearing in the iTunes podcast directory. - :param withhold_from_itunes: Block the podcast. + Note that this will affect more than iTunes, since most podcatchers use + the iTunes catalogue to implement the search feature. Listeners will + still be able to subscribe by adding the feed's address manually. + + If you don't intend on submitting this podcast to iTunes, you can set + this to True as a way of showing iTunes the middle finger (and prevent + others from submitting it as well). + + Set it to ``True`` to withhold the entire podcast from iTunes. It is set + to ``False`` by default, of course. + + :param withhold_from_itunes: ``True`` to block the podcast from iTunes. + :type withhold_from_itunes: bool or None :returns: If the podcast is blocked. """ if not withhold_from_itunes is None: diff --git a/feedgen/item.py b/feedgen/item.py index 596850f..30ec5de 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -322,7 +322,7 @@ def media(self, media=None): return self.__media def withhold_from_itunes(self, withhold_from_itunes=None): - """Get or set the ITunes block attribute. Use this to prevent episodes + """Get or set the iTunes block attribute. Use this to prevent episodes from appearing in the iTunes podcast directory. Note that the episode can still be found by inspecting the XML, so it is still public. diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index a10a635..ea2e780 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -344,5 +344,20 @@ def test_mandatoryValues(self): raise AssertionError("The test failed for %s" % test_property)\ from e + def test_withholdFromItunesOffByDefault(self): + assert not self.fg.withhold_from_itunes() + + def test_withholdFromItunes(self): + self.fg.withhold_from_itunes(True) + itunes_block = self.fg._create_rss().find("channel")\ + .find("{%s}block" % self.nsItunes) + assert itunes_block is not None + self.assertEqual(itunes_block.text.lower(), "yes") + + self.fg.withhold_from_itunes(False) + itunes_block = self.fg._create_rss().find("channel")\ + .find("{%s}block" % self.nsItunes) + assert itunes_block is None + if __name__ == '__main__': unittest.main() From c0d40933ad2fb5b263eae66cae0df854917d64b9 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 3 Jul 2016 00:40:51 +0200 Subject: [PATCH 073/200] Follow RSS best practice when combining summary and content The RSS Best Practices recommend that you use description both for short summaries and full content, as long as both aren't present. If both are present, description should be used for the summary and content:encoded for the full content. A new attribute called long_summary is introduced. --- feedgen/item.py | 41 ++++++++++++++++++++++++++----- feedgen/tests/test_entry.py | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/feedgen/item.py b/feedgen/item.py index 30ec5de..8efd246 100644 --- a/feedgen/item.py +++ b/feedgen/item.py @@ -31,6 +31,7 @@ def __init__(self): # RSS self.__authors = [] self.__summary = None + self.__long_summary = None self.__media = None self.__id = None self.__rss_link = None @@ -66,12 +67,19 @@ def rss_entry(self): link = etree.SubElement(entry, 'link') link.text = self.__rss_link - if self.__summary: - description = etree.SubElement(entry, 'description') - description.text = etree.CDATA(self.__summary) - content = etree.SubElement(entry, '{%s}encoded' % - 'http://purl.org/rss/1.0/modules/content/') - content.text = etree.CDATA(self.__summary) + if self.__summary or self.__long_summary: + if self.__summary and self.__long_summary: + # Both are present, so use both content and description + description = etree.SubElement(entry, 'description') + description.text = etree.CDATA(self.__summary) + content = etree.SubElement(entry, '{%s}encoded' % + 'http://purl.org/rss/1.0/modules/content/') + content.text = etree.CDATA(self.__long_summary) + else: + # Only one is present, use description because of support + description = etree.SubElement(entry, 'description') + description.text = \ + etree.CDATA(self.__summary or self.__long_summary) if self.__authors: authors_with_name = [a.name for a in self.__authors if a.name] @@ -260,6 +268,27 @@ def summary(self, new_summary=None, html=True): self.__summary = new_summary return self.__summary + def long_summary(self, long_summary=None): + """A long (read: full) summary, which supplements the shorter + :attr:`~feedgen.item.BaseEpisode.summary`. + + This attribute should be seen as a full, longer variation of + summary if summary exists. Even then, the long_summary should be + independent from summary, in that you only need to read one of them. + This means you may have to repeat the first sentences. + + If summary does not exist but this does, this is used in place of + summary. + + :param long_summary: A long summary which supplements the shorter + summary. + :type long_summary: str or None + :returns: The long summary which supplements the shorter summary. + """ + if long_summary is not None: + self.__long_summary = long_summary + return self.__long_summary + def link(self, link=None): """Get or set the link to the full version of this episode description. diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index cdd1a8d..cf11027 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -253,3 +253,51 @@ def test_withholdFromItunes(self): self.fe.withhold_from_itunes(False) itunes_block = self.fe.rss_entry().find("{%s}block" % self.itunes_ns) assert itunes_block is None + + def test_summaries(self): + content_encoded = "{%s}encoded" % \ + "http://purl.org/rss/1.0/modules/content/" + # Test that none are in use by default (no summary is set) + d = self.fe.rss_entry().find("description") + assert d is None + ce = self.fe.rss_entry().find(content_encoded) + assert ce is None + + # Test that description is filled when one of the summaries is set + self.fe.summary("A short summary") + d = self.fe.rss_entry().find("description") + assert d is not None + assert "A short summary" in d.text + ce = self.fe.rss_entry().find(content_encoded) + assert ce is None + + self.fe.summary(False) + self.fe.long_summary("A long summary with more words") + d = self.fe.rss_entry().find("description") + assert d is not None + assert "A long summary with more words" in d.text + ce = self.fe.rss_entry().find(content_encoded) + assert ce is None + + # Test that description and content:encoded are used when both are set + self.fe.summary("A short summary") + self.fe.long_summary("A long summary with more words") + d = self.fe.rss_entry().find("description") + assert d is not None + assert "A short summary" in d.text + ce = self.fe.rss_entry().find(content_encoded) + assert ce is not None + assert "A long summary with more words" in ce.text + + def test_summariesHtml(self): + self.fe.summary("A cool summary") + d = self.fe.rss_entry().find("description") + assert d is not None + assert "A cool summary" in d.text + + self.fe.summary("A cool summary", html=False) + d = self.fe.rss_entry().find("description") + assert d is not None + assert "A <b>cool</b> summary" in d.text + + From a6f63b84d69cb12d2900bb4dffdd84e6fdefcd08 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 3 Jul 2016 00:43:41 +0200 Subject: [PATCH 074/200] Add documentation files (for `make doc-html`) to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 54d3eb1..043bda1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ tmp_Atomfeed.xml tmp_Rssfeed.xml # JetBrains IDE .idea/ +/doc/_build +/docs From e5b3472a5919c73aa68a719856f9306fc3cd5e2f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 3 Jul 2016 01:16:37 +0200 Subject: [PATCH 075/200] Add GitHub banner and button --- doc/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index d23c0f0..4eb3703 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -109,6 +109,10 @@ 'gray_1': "rgba(0, 0, 0, 0.9)", 'gray_2': "rgba(0, 0, 0, 0.2)", 'gray_3': "rgba(0, 0, 0, 0.1)", + + 'github_user': 'tobinus', + 'github_repo': 'python-feedgen', + 'github_banner': True, } # Add any paths that contain custom themes here, relative to this directory. From 1ed8e88882d879ea22207c1931824cd38cee0d74 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 4 Jul 2016 11:11:56 +0200 Subject: [PATCH 076/200] Create unit test for Media.create_from_server_response --- feedgen/tests/test_media.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/feedgen/tests/test_media.py b/feedgen/tests/test_media.py index 13c6072..9d33a9d 100644 --- a/feedgen/tests/test_media.py +++ b/feedgen/tests/test_media.py @@ -155,3 +155,36 @@ def test_strToSize(self): for (str_size, expected_size) in iteritems(sizes): self.assertEqual(expected_size, Media._str_to_bytes(str_size)) + + def test_createFromServerResponse(self): + url = self.url + type = self.type + size = self.size + + class MyLittleRequests(object): + @staticmethod + def head(*args, **kwargs): + assert args[0] == url + assert kwargs['allow_redirects'] == True + assert 'timeout' in kwargs + assert 'headers' in kwargs + assert 'User-Agent' in kwargs['headers'] + + class MyLittleResponse(object): + headers = { + 'Content-Type': type, + 'Content-Length': size, + } + + @staticmethod + def raise_for_status(): + pass + + return MyLittleResponse + + m = Media.create_from_server_response(MyLittleRequests, url) + self.assertEqual(m.url, url) + self.assertEqual(m.size, size) + self.assertEqual(m.type, type) + + From 83d8c38dc76f947dab335d9baf7ab5918f3891be Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 4 Jul 2016 11:27:32 +0200 Subject: [PATCH 077/200] Rename modules to match class inside them --- doc/api.episode.rst | 6 ++++ doc/api.feed.rst | 6 ---- doc/api.item.rst | 6 ---- doc/api.podcast.rst | 6 ++++ doc/api.rst | 8 ++--- doc/user/basic_usage_guide/part_1.rst | 52 +++++++++++++-------------- doc/user/basic_usage_guide/part_2.rst | 38 ++++++++++---------- doc/user/basic_usage_guide/part_3.rst | 12 +++---- doc/user/fork.rst | 6 ++-- feedgen/__main__.py | 2 +- feedgen/{item.py => episode.py} | 0 feedgen/{feed.py => podcast.py} | 6 ++-- feedgen/tests/test_entry.py | 2 +- feedgen/tests/test_feed.py | 2 +- 14 files changed, 76 insertions(+), 76 deletions(-) create mode 100644 doc/api.episode.rst delete mode 100644 doc/api.feed.rst delete mode 100644 doc/api.item.rst create mode 100644 doc/api.podcast.rst rename feedgen/{item.py => episode.py} (100%) rename feedgen/{feed.py => podcast.py} (99%) diff --git a/doc/api.episode.rst b/doc/api.episode.rst new file mode 100644 index 0000000..2bef21d --- /dev/null +++ b/doc/api.episode.rst @@ -0,0 +1,6 @@ +=========================== +feedgen.episode.BaseEpisode +=========================== + +.. autoclass:: feedgen.episode.BaseEpisode + :members: diff --git a/doc/api.feed.rst b/doc/api.feed.rst deleted file mode 100644 index 8742c19..0000000 --- a/doc/api.feed.rst +++ /dev/null @@ -1,6 +0,0 @@ -==================== -feedgen.feed.Podcast -==================== - -.. autoclass:: feedgen.feed.Podcast - :members: diff --git a/doc/api.item.rst b/doc/api.item.rst deleted file mode 100644 index 770ffe0..0000000 --- a/doc/api.item.rst +++ /dev/null @@ -1,6 +0,0 @@ -======================== -feedgen.item.BaseEpisode -======================== - -.. autoclass:: feedgen.item.BaseEpisode - :members: diff --git a/doc/api.podcast.rst b/doc/api.podcast.rst new file mode 100644 index 0000000..789177e --- /dev/null +++ b/doc/api.podcast.rst @@ -0,0 +1,6 @@ +======================= +feedgen.podcast.Podcast +======================= + +.. autoclass:: feedgen.podcast.Podcast + :members: diff --git a/doc/api.rst b/doc/api.rst index 19fb531..6d86dcb 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -26,8 +26,8 @@ API Documentation .. autosummary:: - feedgen.feed.Podcast - feedgen.item.BaseEpisode + feedgen.podcast.Podcast + feedgen.episode.BaseEpisode feedgen.person.Person feedgen.media.Media feedgen.category.Category @@ -37,8 +37,8 @@ API Documentation :maxdepth: 2 :hidden: - api.feed - api.item + api.podcast + api.episode api.person api.media api.category diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index 44db799..9fe266f 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -19,19 +19,19 @@ Mandatory properties p.website = "https://example.org" p.explicit = True -Those four properties, :attr:`~feedgen.feed.Podcast.name`, -:attr:`~feedgen.feed.Podcast.description`, -:attr:`~feedgen.feed.Podcast.explicit` and -:attr:`~feedgen.feed.Podcast.website`, are actually +Those four properties, :attr:`~feedgen.podcast.Podcast.name`, +:attr:`~feedgen.podcast.Podcast.description`, +:attr:`~feedgen.podcast.Podcast.explicit` and +:attr:`~feedgen.podcast.Podcast.website`, are actually the only four **mandatory** properties of -:class:`~feedgen.feed.Podcast`. A summary of them: +:class:`~feedgen.podcast.Podcast`. A summary of them: .. autosummary:: - ~feedgen.feed.Podcast.name - ~feedgen.feed.Podcast.description - ~feedgen.feed.Podcast.website - ~feedgen.feed.Podcast.explicit + ~feedgen.podcast.Podcast.name + ~feedgen.podcast.Podcast.description + ~feedgen.podcast.Podcast.website + ~feedgen.podcast.Podcast.explicit Image ~~~~~ @@ -40,7 +40,7 @@ A podcast's image is worth special attention:: p.image = "https://example.com/static/example_podcast.png" -.. automethod:: feedgen.feed.Podcast.image +.. automethod:: feedgen.podcast.Podcast.image :noindex: Even though the image *technically* is optional, you won't reach people without it. @@ -49,7 +49,7 @@ Optional properties ~~~~~~~~~~~~~~~~~~~ There are plenty of other properties that can be used with -:class:`feedgen.feed.Podcast `: +:class:`feedgen.podcast.Podcast `: Commonly used @@ -66,12 +66,12 @@ Commonly used .. autosummary:: - ~feedgen.feed.Podcast.copyright - ~feedgen.feed.Podcast.language - ~feedgen.feed.Podcast.authors - ~feedgen.feed.Podcast.feed_url - ~feedgen.feed.Podcast.category - ~feedgen.feed.Podcast.owner + ~feedgen.podcast.Podcast.copyright + ~feedgen.podcast.Podcast.language + ~feedgen.podcast.Podcast.authors + ~feedgen.podcast.Podcast.feed_url + ~feedgen.podcast.Podcast.category + ~feedgen.podcast.Podcast.owner Less commonly used @@ -101,15 +101,15 @@ full description. .. autosummary:: - ~feedgen.feed.Podcast.cloud - ~feedgen.feed.Podcast.last_updated - ~feedgen.feed.Podcast.publication_date - ~feedgen.feed.Podcast.skip_days - ~feedgen.feed.Podcast.skip_hours - ~feedgen.feed.Podcast.web_master - ~feedgen.feed.Podcast.new_feed_url - ~feedgen.feed.Podcast.complete - ~feedgen.feed.Podcast.withhold_from_itunes + ~feedgen.podcast.Podcast.cloud + ~feedgen.podcast.Podcast.last_updated + ~feedgen.podcast.Podcast.publication_date + ~feedgen.podcast.Podcast.skip_days + ~feedgen.podcast.Podcast.skip_hours + ~feedgen.podcast.Podcast.web_master + ~feedgen.podcast.Podcast.new_feed_url + ~feedgen.podcast.Podcast.complete + ~feedgen.podcast.Podcast.withhold_from_itunes Next step is :doc:`part_2`. diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index 0f9ed30..3d3e7d5 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -11,7 +11,7 @@ straight-forward:: my_episode = Episode() p.episodes.append(my_episode) -There is a convenience method called :meth:`Podcast.add_episode ` +There is a convenience method called :meth:`Podcast.add_episode ` which optionally creates a new instance of :class:`~feedgen.episode.Episode`, adds it to the podcast and returns it, allowing you to assign it to a variable:: @@ -44,10 +44,10 @@ They're all pretty obvious: .. autosummary:: - ~feedgen.item.BaseEpisode.title - ~feedgen.item.BaseEpisode.subtitle - ~feedgen.item.BaseEpisode.summary - ~feedgen.item.BaseEpisode.long_summary + ~feedgen.episode.BaseEpisode.title + ~feedgen.episode.BaseEpisode.subtitle + ~feedgen.episode.BaseEpisode.summary + ~feedgen.episode.BaseEpisode.long_summary Enclosing media @@ -93,7 +93,7 @@ and listening. .. autosummary:: - ~feedgen.item.BaseEpisode.media + ~feedgen.episode.BaseEpisode.media ~feedgen.media.Media @@ -111,7 +111,7 @@ That is, given the example above, the id of ``my_episode`` would be An episode's ID should never change. Therefore, **if you don't set id, the media URL must never change either**. -.. autosummary:: ~feedgen.item.BaseEpisode.id +.. autosummary:: ~feedgen.episode.BaseEpisode.id Episode's publication date @@ -135,7 +135,7 @@ will get a new episode which appears to have existed for longer than it has. my_episode.publication_date = datetime.datetime(2016, 5, 18, 10, 0, tzinfo=pytz.utc) -.. autosummary:: ~feedgen.item.BaseEpisode.publication_date +.. autosummary:: ~feedgen.episode.BaseEpisode.publication_date The Link @@ -144,7 +144,7 @@ The Link If you're publishing articles along with your podcast episodes, you should link to the relevant article. Examples can be linking to the sound on SoundCloud or the post on your website. Usually, your -listeners expect to find the entirety of the :attr:`~feedgen.item.BaseEpisode.summary` by following +listeners expect to find the entirety of the :attr:`~feedgen.episode.BaseEpisode.summary` by following the link. :: my_episode.link = "http://example.com/article/2016/05/18/Best-example" @@ -154,7 +154,7 @@ the link. :: If you don't have anything to link to, then that's fine as well. No link is better than a disappointing link. -.. autosummary:: ~feedgen.item.BaseEpisode.link +.. autosummary:: ~feedgen.episode.BaseEpisode.link The Authors @@ -163,12 +163,12 @@ The Authors .. note:: Some of those attributes correspond to attributes found in - :class:`~feedgen.feed.Podcast`. In such cases, you should only set those + :class:`~feedgen.podcast.Podcast`. In such cases, you should only set those attributes at the episode level if they **differ** from their value at the podcast level. -Normally, the attributes :attr:`Podcast.authors ` -and :attr:`Podcast.web_master ` (if set) are +Normally, the attributes :attr:`Podcast.authors ` +and :attr:`Podcast.web_master ` (if set) are used to determine the authors of an episode. Thus, if all your episodes have the same authors, you should just set it at the podcast level. @@ -181,7 +181,7 @@ You can even have multiple authors:: my_episode.authors = [Person("Joe Bob"), Person("Alice Bob")] -.. autosummary:: ~feedgen.item.BaseEpisode.authors +.. autosummary:: ~feedgen.episode.BaseEpisode.authors Less used attributes @@ -198,10 +198,10 @@ Less used attributes .. autosummary:: - ~feedgen.item.BaseEpisode.image - ~feedgen.item.BaseEpisode.explicit - ~feedgen.item.BaseEpisode.is_closed_captioned - ~feedgen.item.BaseEpisode.position - ~feedgen.item.BaseEpisode.withhold_from_itunes + ~feedgen.episode.BaseEpisode.image + ~feedgen.episode.BaseEpisode.explicit + ~feedgen.episode.BaseEpisode.is_closed_captioned + ~feedgen.episode.BaseEpisode.position + ~feedgen.episode.BaseEpisode.withhold_from_itunes The final step is :doc:`part_3` diff --git a/doc/user/basic_usage_guide/part_3.rst b/doc/user/basic_usage_guide/part_3.rst index 828a71e..1a29978 100644 --- a/doc/user/basic_usage_guide/part_3.rst +++ b/doc/user/basic_usage_guide/part_3.rst @@ -9,28 +9,28 @@ take the final step:: # Print to stdout, just as an example print(rssfeed) -If you're okay with the default parameters of :meth:`feedgen.feed.Podcast.rss_str`, -you can use a shortcut by converting :class:`~feedgen.feed.Podcast` to :obj:`str`:: +If you're okay with the default parameters of :meth:`feedgen.podcast.Podcast.rss_str`, +you can use a shortcut by converting :class:`~feedgen.podcast.Podcast` to :obj:`str`:: rssfeed = str(p) # Or let print convert to str for you print(p) -Doing so is the same as calling :meth:`feedgen.feed.Podcast.rss_str` with no +Doing so is the same as calling :meth:`feedgen.podcast.Podcast.rss_str` with no parameters. .. autosummary:: - ~feedgen.feed.Podcast.rss_str + ~feedgen.podcast.Podcast.rss_str -You may also write the feed to a file directly, using :meth:`feedgen.feed.Podcast.rss_file`:: +You may also write the feed to a file directly, using :meth:`feedgen.podcast.Podcast.rss_file`:: fg.rss_file('rss.xml', minimize=True) .. autosummary:: - ~feedgen.feed.Podcast.rss_file + ~feedgen.podcast.Podcast.rss_file This concludes the basic usage guide. You might want to look at the :doc:`../example` or the :doc:`/api`. diff --git a/doc/user/fork.rst b/doc/user/fork.rst index 2527b2f..df49ef3 100644 --- a/doc/user/fork.rst +++ b/doc/user/fork.rst @@ -70,8 +70,8 @@ bring it there, so it can benefit **everyone**. Summary of changes ------------------ -* ``FeedGenerator`` is renamed to :class:`~feedgen.feed.Podcast` and ``FeedItem`` is accessed - at ``Podcast.Episode`` (or directly: :class:`~feedgen.item.BaseEpisode`). +* ``FeedGenerator`` is renamed to :class:`~feedgen.podcast.Podcast` and ``FeedItem`` is accessed + at ``Podcast.Episode`` (or directly: :class:`~feedgen.episode.BaseEpisode`). * Support for ATOM removed. * Move from using getter and setter methods to using properties, which you can assign just like you would assign any other property. @@ -91,6 +91,6 @@ Summary of changes * Rename the remaining properties so their names don't necessarily match the RSS elements they map to. Instead, the names should be descriptive and easy to understand. -* Add shorthand for generating the RSS: Just try to converting your :class:`~feedgen.feed.Podcast` +* Add shorthand for generating the RSS: Just try to converting your :class:`~feedgen.podcast.Podcast` object to :obj:`str`! * Improve the documentation diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 06d8d25..da851b0 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -42,7 +42,7 @@ def main(): # Remember what type of feed the user wants arg = sys.argv[1] - from feedgen.feed import Podcast + from feedgen.podcast import Podcast from feedgen.person import Person from feedgen.media import Media from feedgen.category import Category diff --git a/feedgen/item.py b/feedgen/episode.py similarity index 100% rename from feedgen/item.py rename to feedgen/episode.py diff --git a/feedgen/feed.py b/feedgen/podcast.py similarity index 99% rename from feedgen/feed.py rename to feedgen/podcast.py index 0f6b3d4..f448841 100644 --- a/feedgen/feed.py +++ b/feedgen/podcast.py @@ -13,7 +13,7 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.item import BaseEpisode +from feedgen.episode import BaseEpisode from feedgen.util import ensure_format, formatRFC2822, listToHumanreadableStr from feedgen.person import Person import feedgen.version @@ -119,7 +119,7 @@ def Episode(self): Example of use:: >>> # Create new podcast - >>> from feedgen.feed import Podcast + >>> from feedgen.podcast import Podcast >>> p = Podcast() >>> # Here's how you would create a new episode object, the OK way @@ -666,7 +666,7 @@ def skip_hours(self, hours=None, replace=False): For example, to skip hours between 18 and 7:: - >>> from feedgen.feed import Podcast + >>> from feedgen.podcast import Podcast >>> p = Podcast() >>> p.skip_hours(range(18, 24)) {18, 19, 20, 21, 22, 23} diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index cf11027..f6fe2f8 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -11,7 +11,7 @@ from feedgen.person import Person from feedgen.media import Media -from ..feed import Podcast +from ..podcast import Podcast import datetime import pytz from dateutil.parser import parse as parsedate diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index ea2e780..e3a4a90 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -12,7 +12,7 @@ from feedgen.person import Person from feedgen.category import Category -from ..feed import Podcast +from ..podcast import Podcast import feedgen.version import datetime import dateutil.tz From 5f0b5f505f814d0c5a670197fe5fe2acef0519a8 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 4 Jul 2016 11:53:21 +0200 Subject: [PATCH 078/200] Expose core classes at package level This way, you don't need to import from the individual modules. --- doc/api.category.rst | 6 ++-- doc/api.episode.rst | 8 ++--- doc/api.media.rst | 6 ++-- doc/api.person.rst | 8 ++--- doc/api.podcast.rst | 8 ++--- doc/api.rst | 10 +++--- doc/index.rst | 16 ++++----- doc/user/basic_usage_guide/part_1.rst | 52 +++++++++++++-------------- doc/user/basic_usage_guide/part_2.rst | 38 ++++++++++---------- doc/user/basic_usage_guide/part_3.rst | 12 +++---- doc/user/fork.rst | 6 ++-- feedgen/__init__.py | 9 +++-- feedgen/__main__.py | 5 +-- feedgen/person.py | 2 +- feedgen/podcast.py | 12 +++---- feedgen/tests/test_category.py | 2 +- feedgen/tests/test_entry.py | 4 +-- feedgen/tests/test_feed.py | 4 +-- feedgen/tests/test_media.py | 4 +-- feedgen/tests/test_person.py | 2 +- 20 files changed, 105 insertions(+), 109 deletions(-) diff --git a/doc/api.category.rst b/doc/api.category.rst index 3bd3d35..6042b29 100644 --- a/doc/api.category.rst +++ b/doc/api.category.rst @@ -1,5 +1,5 @@ -feedgen.category.Category -========================= +feedgen.Category +================ -.. autoclass:: feedgen.category.Category +.. autoclass:: feedgen.Category :members: diff --git a/doc/api.episode.rst b/doc/api.episode.rst index 2bef21d..7c0d8fb 100644 --- a/doc/api.episode.rst +++ b/doc/api.episode.rst @@ -1,6 +1,6 @@ -=========================== -feedgen.episode.BaseEpisode -=========================== +=================== +feedgen.BaseEpisode +=================== -.. autoclass:: feedgen.episode.BaseEpisode +.. autoclass:: feedgen.BaseEpisode :members: diff --git a/doc/api.media.rst b/doc/api.media.rst index 593a7ce..d9209c9 100644 --- a/doc/api.media.rst +++ b/doc/api.media.rst @@ -1,5 +1,5 @@ -feedgen.media.Media -=================== +feedgen.Media +============= -.. autoclass:: feedgen.media.Media +.. autoclass:: feedgen.Media :members: diff --git a/doc/api.person.rst b/doc/api.person.rst index a2a0312..e0b4736 100644 --- a/doc/api.person.rst +++ b/doc/api.person.rst @@ -1,6 +1,6 @@ -===================== -feedgen.person.Person -===================== +============== +feedgen.Person +============== -.. autoclass:: feedgen.person.Person +.. autoclass:: feedgen.Person :members: diff --git a/doc/api.podcast.rst b/doc/api.podcast.rst index 789177e..e58d6b7 100644 --- a/doc/api.podcast.rst +++ b/doc/api.podcast.rst @@ -1,6 +1,6 @@ -======================= -feedgen.podcast.Podcast -======================= +=============== +feedgen.Podcast +=============== -.. autoclass:: feedgen.podcast.Podcast +.. autoclass:: feedgen.Podcast :members: diff --git a/doc/api.rst b/doc/api.rst index 6d86dcb..a686229 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -26,11 +26,11 @@ API Documentation .. autosummary:: - feedgen.podcast.Podcast - feedgen.episode.BaseEpisode - feedgen.person.Person - feedgen.media.Media - feedgen.category.Category + feedgen.Podcast + feedgen.BaseEpisode + feedgen.Person + feedgen.Media + feedgen.Category feedgen.util .. toctree:: diff --git a/doc/index.rst b/doc/index.rst index 85718b7..6f3186d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -11,7 +11,7 @@ PodcastGenerator Wouldn't it be nice if there was a clean, simple library which could help you generate podcast RSS feeds from your Python code? Well, today's your lucky day! - >>> from feedgen import Podcast + >>> from feedgen import Podcast, Episode, Media >>> # Create the Podcast >>> p = Podcast( name="My Awesome Podcast", @@ -21,13 +21,13 @@ generate podcast RSS feeds from your Python code? Well, today's your lucky day! ) >>> # Add some episodes >>> p.episodes += [ - p.Episode(title="PodcastGenerator rocks!", - media=p.Media("http://example.org/ep1.mp3", 11932295), - summary="I found an awesome library for creating podcasts"), - p.Episode(title="Heard about clint?", - media=p.Media("http://example.org/ep2.mp3", 15363464), - summary="The man behind Requests made something useful " - "for us command-line lovers." + Episode(title="PodcastGenerator rocks!", + media=Media("http://example.org/ep1.mp3", 11932295), + summary="I found an awesome library for creating podcasts"), + Episode(title="Heard about clint?", + media=Media("http://example.org/ep2.mp3", 15363464), + summary="The man behind Requests made something useful " + "for us command-line lovers." ] >>> # Generate the RSS feed >>> rss = str(p) diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index 9fe266f..fee628b 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -19,19 +19,19 @@ Mandatory properties p.website = "https://example.org" p.explicit = True -Those four properties, :attr:`~feedgen.podcast.Podcast.name`, -:attr:`~feedgen.podcast.Podcast.description`, -:attr:`~feedgen.podcast.Podcast.explicit` and -:attr:`~feedgen.podcast.Podcast.website`, are actually +Those four properties, :attr:`~feedgen.Podcast.name`, +:attr:`~feedgen.Podcast.description`, +:attr:`~feedgen.Podcast.explicit` and +:attr:`~feedgen.Podcast.website`, are actually the only four **mandatory** properties of -:class:`~feedgen.podcast.Podcast`. A summary of them: +:class:`~feedgen.Podcast`. A summary of them: .. autosummary:: - ~feedgen.podcast.Podcast.name - ~feedgen.podcast.Podcast.description - ~feedgen.podcast.Podcast.website - ~feedgen.podcast.Podcast.explicit + ~feedgen.Podcast.name + ~feedgen.Podcast.description + ~feedgen.Podcast.website + ~feedgen.Podcast.explicit Image ~~~~~ @@ -40,7 +40,7 @@ A podcast's image is worth special attention:: p.image = "https://example.com/static/example_podcast.png" -.. automethod:: feedgen.podcast.Podcast.image +.. automethod:: feedgen.Podcast.image :noindex: Even though the image *technically* is optional, you won't reach people without it. @@ -49,7 +49,7 @@ Optional properties ~~~~~~~~~~~~~~~~~~~ There are plenty of other properties that can be used with -:class:`feedgen.podcast.Podcast `: +:class:`feedgen.Podcast `: Commonly used @@ -66,12 +66,12 @@ Commonly used .. autosummary:: - ~feedgen.podcast.Podcast.copyright - ~feedgen.podcast.Podcast.language - ~feedgen.podcast.Podcast.authors - ~feedgen.podcast.Podcast.feed_url - ~feedgen.podcast.Podcast.category - ~feedgen.podcast.Podcast.owner + ~feedgen.Podcast.copyright + ~feedgen.Podcast.language + ~feedgen.Podcast.authors + ~feedgen.Podcast.feed_url + ~feedgen.Podcast.category + ~feedgen.Podcast.owner Less commonly used @@ -101,15 +101,15 @@ full description. .. autosummary:: - ~feedgen.podcast.Podcast.cloud - ~feedgen.podcast.Podcast.last_updated - ~feedgen.podcast.Podcast.publication_date - ~feedgen.podcast.Podcast.skip_days - ~feedgen.podcast.Podcast.skip_hours - ~feedgen.podcast.Podcast.web_master - ~feedgen.podcast.Podcast.new_feed_url - ~feedgen.podcast.Podcast.complete - ~feedgen.podcast.Podcast.withhold_from_itunes + ~feedgen.Podcast.cloud + ~feedgen.Podcast.last_updated + ~feedgen.Podcast.publication_date + ~feedgen.Podcast.skip_days + ~feedgen.Podcast.skip_hours + ~feedgen.Podcast.web_master + ~feedgen.Podcast.new_feed_url + ~feedgen.Podcast.complete + ~feedgen.Podcast.withhold_from_itunes Next step is :doc:`part_2`. diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index 3d3e7d5..c024e16 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -11,7 +11,7 @@ straight-forward:: my_episode = Episode() p.episodes.append(my_episode) -There is a convenience method called :meth:`Podcast.add_episode ` +There is a convenience method called :meth:`Podcast.add_episode ` which optionally creates a new instance of :class:`~feedgen.episode.Episode`, adds it to the podcast and returns it, allowing you to assign it to a variable:: @@ -44,10 +44,10 @@ They're all pretty obvious: .. autosummary:: - ~feedgen.episode.BaseEpisode.title - ~feedgen.episode.BaseEpisode.subtitle - ~feedgen.episode.BaseEpisode.summary - ~feedgen.episode.BaseEpisode.long_summary + ~feedgen.BaseEpisode.title + ~feedgen.BaseEpisode.subtitle + ~feedgen.BaseEpisode.summary + ~feedgen.BaseEpisode.long_summary Enclosing media @@ -93,7 +93,7 @@ and listening. .. autosummary:: - ~feedgen.episode.BaseEpisode.media + ~feedgen.BaseEpisode.media ~feedgen.media.Media @@ -111,7 +111,7 @@ That is, given the example above, the id of ``my_episode`` would be An episode's ID should never change. Therefore, **if you don't set id, the media URL must never change either**. -.. autosummary:: ~feedgen.episode.BaseEpisode.id +.. autosummary:: ~feedgen.BaseEpisode.id Episode's publication date @@ -135,7 +135,7 @@ will get a new episode which appears to have existed for longer than it has. my_episode.publication_date = datetime.datetime(2016, 5, 18, 10, 0, tzinfo=pytz.utc) -.. autosummary:: ~feedgen.episode.BaseEpisode.publication_date +.. autosummary:: ~feedgen.BaseEpisode.publication_date The Link @@ -144,7 +144,7 @@ The Link If you're publishing articles along with your podcast episodes, you should link to the relevant article. Examples can be linking to the sound on SoundCloud or the post on your website. Usually, your -listeners expect to find the entirety of the :attr:`~feedgen.episode.BaseEpisode.summary` by following +listeners expect to find the entirety of the :attr:`~feedgen.BaseEpisode.summary` by following the link. :: my_episode.link = "http://example.com/article/2016/05/18/Best-example" @@ -154,7 +154,7 @@ the link. :: If you don't have anything to link to, then that's fine as well. No link is better than a disappointing link. -.. autosummary:: ~feedgen.episode.BaseEpisode.link +.. autosummary:: ~feedgen.BaseEpisode.link The Authors @@ -163,12 +163,12 @@ The Authors .. note:: Some of those attributes correspond to attributes found in - :class:`~feedgen.podcast.Podcast`. In such cases, you should only set those + :class:`~feedgen.Podcast`. In such cases, you should only set those attributes at the episode level if they **differ** from their value at the podcast level. -Normally, the attributes :attr:`Podcast.authors ` -and :attr:`Podcast.web_master ` (if set) are +Normally, the attributes :attr:`Podcast.authors ` +and :attr:`Podcast.web_master ` (if set) are used to determine the authors of an episode. Thus, if all your episodes have the same authors, you should just set it at the podcast level. @@ -181,7 +181,7 @@ You can even have multiple authors:: my_episode.authors = [Person("Joe Bob"), Person("Alice Bob")] -.. autosummary:: ~feedgen.episode.BaseEpisode.authors +.. autosummary:: ~feedgen.BaseEpisode.authors Less used attributes @@ -198,10 +198,10 @@ Less used attributes .. autosummary:: - ~feedgen.episode.BaseEpisode.image - ~feedgen.episode.BaseEpisode.explicit - ~feedgen.episode.BaseEpisode.is_closed_captioned - ~feedgen.episode.BaseEpisode.position - ~feedgen.episode.BaseEpisode.withhold_from_itunes + ~feedgen.BaseEpisode.image + ~feedgen.BaseEpisode.explicit + ~feedgen.BaseEpisode.is_closed_captioned + ~feedgen.BaseEpisode.position + ~feedgen.BaseEpisode.withhold_from_itunes The final step is :doc:`part_3` diff --git a/doc/user/basic_usage_guide/part_3.rst b/doc/user/basic_usage_guide/part_3.rst index 1a29978..485ac25 100644 --- a/doc/user/basic_usage_guide/part_3.rst +++ b/doc/user/basic_usage_guide/part_3.rst @@ -9,28 +9,28 @@ take the final step:: # Print to stdout, just as an example print(rssfeed) -If you're okay with the default parameters of :meth:`feedgen.podcast.Podcast.rss_str`, -you can use a shortcut by converting :class:`~feedgen.podcast.Podcast` to :obj:`str`:: +If you're okay with the default parameters of :meth:`feedgen.Podcast.rss_str`, +you can use a shortcut by converting :class:`~feedgen.Podcast` to :obj:`str`:: rssfeed = str(p) # Or let print convert to str for you print(p) -Doing so is the same as calling :meth:`feedgen.podcast.Podcast.rss_str` with no +Doing so is the same as calling :meth:`feedgen.Podcast.rss_str` with no parameters. .. autosummary:: - ~feedgen.podcast.Podcast.rss_str + ~feedgen.Podcast.rss_str -You may also write the feed to a file directly, using :meth:`feedgen.podcast.Podcast.rss_file`:: +You may also write the feed to a file directly, using :meth:`feedgen.Podcast.rss_file`:: fg.rss_file('rss.xml', minimize=True) .. autosummary:: - ~feedgen.podcast.Podcast.rss_file + ~feedgen.Podcast.rss_file This concludes the basic usage guide. You might want to look at the :doc:`../example` or the :doc:`/api`. diff --git a/doc/user/fork.rst b/doc/user/fork.rst index df49ef3..fa9d087 100644 --- a/doc/user/fork.rst +++ b/doc/user/fork.rst @@ -70,8 +70,8 @@ bring it there, so it can benefit **everyone**. Summary of changes ------------------ -* ``FeedGenerator`` is renamed to :class:`~feedgen.podcast.Podcast` and ``FeedItem`` is accessed - at ``Podcast.Episode`` (or directly: :class:`~feedgen.episode.BaseEpisode`). +* ``FeedGenerator`` is renamed to :class:`~feedgen.Podcast` and ``FeedItem`` is accessed + at ``Podcast.Episode`` (or directly: :class:`~feedgen.BaseEpisode`). * Support for ATOM removed. * Move from using getter and setter methods to using properties, which you can assign just like you would assign any other property. @@ -91,6 +91,6 @@ Summary of changes * Rename the remaining properties so their names don't necessarily match the RSS elements they map to. Instead, the names should be descriptive and easy to understand. -* Add shorthand for generating the RSS: Just try to converting your :class:`~feedgen.podcast.Podcast` +* Add shorthand for generating the RSS: Just try to converting your :class:`~feedgen.Podcast` object to :obj:`str`! * Improve the documentation diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 4cbd410..c0dfb11 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- -""" - -""" +from .podcast import Podcast +from .episode import BaseEpisode +from .media import Media +from .person import Person +from .not_supported_by_itunes_warning import NotSupportedByItunesWarning +from .category import Category diff --git a/feedgen/__main__.py b/feedgen/__main__.py index da851b0..c20f829 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -42,10 +42,7 @@ def main(): # Remember what type of feed the user wants arg = sys.argv[1] - from feedgen.podcast import Podcast - from feedgen.person import Person - from feedgen.media import Media - from feedgen.category import Category + from feedgen import Podcast, Person, Media, Category # Initialize the feed p = Podcast() p.name('Testfeed') diff --git a/feedgen/person.py b/feedgen/person.py index a339533..03d6b40 100644 --- a/feedgen/person.py +++ b/feedgen/person.py @@ -22,7 +22,7 @@ class Person(object): Example of use:: - >>> from feedgen.person import Person + >>> from feedgen import Person >>> Person("John Doe") Person(name=John Doe, email=None) >>> Person(email="johndoe@example.org") diff --git a/feedgen/podcast.py b/feedgen/podcast.py index f448841..e099878 100644 --- a/feedgen/podcast.py +++ b/feedgen/podcast.py @@ -31,10 +31,10 @@ class Podcast(object): The following attributes are mandatory: - * :attr:`~feedgen.podcast.Podcast.name` - * :attr:`~feedgen.podcast.Podcast.website` - * :attr:`~feedgen.podcast.Podcast.description` - * :attr:`~feedgen.podcast.Podcast.explicit` + * :attr:`~feedgen.Podcast.name` + * :attr:`~feedgen.Podcast.website` + * :attr:`~feedgen.Podcast.description` + * :attr:`~feedgen.Podcast.explicit` """ @@ -119,7 +119,7 @@ def Episode(self): Example of use:: >>> # Create new podcast - >>> from feedgen.podcast import Podcast + >>> from feedgen import Podcast >>> p = Podcast() >>> # Here's how you would create a new episode object, the OK way @@ -666,7 +666,7 @@ def skip_hours(self, hours=None, replace=False): For example, to skip hours between 18 and 7:: - >>> from feedgen.podcast import Podcast + >>> from feedgen import Podcast >>> p = Podcast() >>> p.skip_hours(range(18, 24)) {18, 19, 20, 21, 22, 23} diff --git a/feedgen/tests/test_category.py b/feedgen/tests/test_category.py index 179eb94..2d5425c 100644 --- a/feedgen/tests/test_category.py +++ b/feedgen/tests/test_category.py @@ -1,6 +1,6 @@ import unittest -from feedgen.category import Category +from feedgen import Category class TestCategory(unittest.TestCase): diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index f6fe2f8..8b50687 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -9,9 +9,7 @@ import unittest from lxml import etree -from feedgen.person import Person -from feedgen.media import Media -from ..podcast import Podcast +from feedgen import Person, Media, Podcast import datetime import pytz from dateutil.parser import parse as parsedate diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index e3a4a90..2e9fa44 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -10,9 +10,7 @@ import unittest from lxml import etree -from feedgen.person import Person -from feedgen.category import Category -from ..podcast import Podcast +from feedgen import Person, Category, Podcast import feedgen.version import datetime import dateutil.tz diff --git a/feedgen/tests/test_media.py b/feedgen/tests/test_media.py index 9d33a9d..20bb980 100644 --- a/feedgen/tests/test_media.py +++ b/feedgen/tests/test_media.py @@ -2,8 +2,8 @@ import unittest import warnings -from feedgen.media import Media -from feedgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from feedgen import Media, NotSupportedByItunesWarning + class TestMedia(unittest.TestCase): def setUp(self): diff --git a/feedgen/tests/test_person.py b/feedgen/tests/test_person.py index 24930c1..d8f428c 100644 --- a/feedgen/tests/test_person.py +++ b/feedgen/tests/test_person.py @@ -1,5 +1,5 @@ import unittest -from ..person import Person +from feedgen import Person class TestPerson(unittest.TestCase): def setUp(self): From b3dc213b076eae31573e7105d91ff81334d518c8 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 5 Jul 2016 17:49:00 +0200 Subject: [PATCH 079/200] Change from getters/setters to attributes in Podcast --- doc/user/basic_usage_guide/part_1.rst | 52 +- feedgen/__main__.py | 22 +- feedgen/podcast.py | 777 ++++++++++++-------------- feedgen/tests/test_entry.py | 12 +- feedgen/tests/test_feed.py | 111 ++-- 5 files changed, 470 insertions(+), 504 deletions(-) diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index fee628b..e3936da 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -24,14 +24,7 @@ Those four properties, :attr:`~feedgen.Podcast.name`, :attr:`~feedgen.Podcast.explicit` and :attr:`~feedgen.Podcast.website`, are actually the only four **mandatory** properties of -:class:`~feedgen.Podcast`. A summary of them: - -.. autosummary:: - - ~feedgen.Podcast.name - ~feedgen.Podcast.description - ~feedgen.Podcast.website - ~feedgen.Podcast.explicit +:class:`~feedgen.Podcast`. Image ~~~~~ @@ -40,7 +33,7 @@ A podcast's image is worth special attention:: p.image = "https://example.com/static/example_podcast.png" -.. automethod:: feedgen.Podcast.image +.. autoattribute:: feedgen.Podcast.image :noindex: Even though the image *technically* is optional, you won't reach people without it. @@ -64,26 +57,25 @@ Commonly used p.category = Category("Technology", "Podcasting") p.owner = p.authors[0] -.. autosummary:: +Read more: - ~feedgen.Podcast.copyright - ~feedgen.Podcast.language - ~feedgen.Podcast.authors - ~feedgen.Podcast.feed_url - ~feedgen.Podcast.category - ~feedgen.Podcast.owner +* :attr:`~feedgen.Podcast.copyright` +* :attr:`~feedgen.Podcast.language` +* :attr:`~feedgen.Podcast.authors` +* :attr:`~feedgen.Podcast.feed_url` +* :attr:`~feedgen.Podcast.category` +* :attr:`~feedgen.Podcast.owner` Less commonly used ^^^^^^^^^^^^^^^^^^ Some of those are obscure while some of them are often times not needed. Others -again have very reasonable defaults. Remember to click on a name to read its -full description. +again have very reasonable defaults. :: - p.cloud = ("server.example.com", "/rpc", 80, "xml-rpc") + p.cloud = ("server.example.com", 80, "/rpc", "cloud.notify", "xml-rpc") import datetime import pytz @@ -99,17 +91,17 @@ full description. p.complete = True p.withhold_from_itunes = True -.. autosummary:: - - ~feedgen.Podcast.cloud - ~feedgen.Podcast.last_updated - ~feedgen.Podcast.publication_date - ~feedgen.Podcast.skip_days - ~feedgen.Podcast.skip_hours - ~feedgen.Podcast.web_master - ~feedgen.Podcast.new_feed_url - ~feedgen.Podcast.complete - ~feedgen.Podcast.withhold_from_itunes +Read more: + +* :attr:`~feedgen.Podcast.cloud` +* :attr:`~feedgen.Podcast.last_updated` +* :attr:`~feedgen.Podcast.publication_date` +* :attr:`~feedgen.Podcast.skip_days` +* :attr:`~feedgen.Podcast.skip_hours` +* :attr:`~feedgen.Podcast.web_master` +* :attr:`~feedgen.Podcast.new_feed_url` +* :attr:`~feedgen.Podcast.complete` +* :attr:`~feedgen.Podcast.withhold_from_itunes` Next step is :doc:`part_2`. diff --git a/feedgen/__main__.py b/feedgen/__main__.py index c20f829..1fe597e 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -45,18 +45,18 @@ def main(): from feedgen import Podcast, Person, Media, Category # Initialize the feed p = Podcast() - p.name('Testfeed') + p.name = 'Testfeed' p.authors.append(Person("Lars Kiesow", "lkiesow@uos.de")) - p.website(href='http://example.com') - p.copyright('cc-by') - p.description('This is a cool feed!') - p.language('de') - p.feed_url('http://example.com/feeds/myfeed.rss') - p.category(Category('Technology', 'Podcasting')) - p.explicit(False) - p.complete('no') - p.new_feed_url('http://example.com/new-feed.rss') - p.owner(Person('John Doe', 'john@example.com')) + p.website = 'http://example.com' + p.copyright = 'cc-by' + p.description = 'This is a cool feed!' + p.language = 'de' + p.feed_url = 'http://example.com/feeds/myfeed.rss' + p.category = Category('Technology', 'Podcasting') + p.explicit = False + p.complete = False + p.new_feed_url = 'http://example.com/new-feed.rss' + p.owner = Person('John Doe', 'john@example.com') e1 = p.add_episode() e1.id('http://lernfunk.de/_MEDIAID_123#1') diff --git a/feedgen/podcast.py b/feedgen/podcast.py index e099878..9650a04 100644 --- a/feedgen/podcast.py +++ b/feedgen/podcast.py @@ -47,35 +47,149 @@ def __init__(self): ## RSS # http://www.rssboard.org/rss-specification # Mandatory: - self.__name = None - self.__website = None - self.__description = None - self.__explicit = None + self.name = None + """The name of the podcast. It should be a human + readable title. Often the same as the title of the + associated website. This is mandatory for RSS and must + not be blank. + + This will set rss:title. + """ + + self.website = None + """This podcast's website's absolute URL. + + One of the mandatory attributes. + + This corresponds to the RSS link element. + """ + + self.description = None + """The description of the feed, which is a phrase or sentence describing + the channel. It is mandatory for RSS feeds, and is shown under the + podcast's name on the iTunes store page.""" + + self.explicit = None + """Whether this podcast may be inappropriate for children or not. + + This is one of the mandatory attributes, and can seen as the + default for episodes. Individual episodes can be marked as explicit + or clean independently from the podcast. + + If you set this to ``True``, an "explicit" parental advisory + graphic will appear next to your podcast artwork on the iTunes Store and + in the Name column in iTunes. If it is set to ``False``, + 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.""" # Optional: self.__cloud = None - self.__copyright = None + + self.copyright = None + """The copyright notice for content in this podcast. + + This should be human-readable. For example, "Copyright 2016 Example + Radio". + + Note that even if you leave out the copyright notice, your content is + still protected by copyright (unless anything else is indicated), since + you do not need a copyright statement for something to be protected by + copyright. If you intend to put the podcast in public domain or license + it under a Creative Commons license, you should say so in the copyright + notice.""" + self.__docs = 'http://www.rssboard.org/rss-specification' - self.__generator = self._feedgen_generator_str - self.__language = None + + self.generator = self._feedgen_generator_str + """A string identifying the software that generated this RSS feed. + Defaults to a string identifying PodcastGenerator. + + .. seealso:: + + The :py:meth:`.set_generator` method + A convenient way to set the generator value and include version + and url. + """ + + self.language = None + """The language of the podcast. + + This allows aggregators to group all Italian + language podcasts, for example, on a single page. + + It must be a two-letter code, as found in ISO639-1, with the + possibility of specifying subcodes (eg. en-US for American English). + See http://www.rssboard.org/rss-language-codes and + http://www.loc.gov/standards/iso639-2/php/code_list.php""" + self.__last_updated = None + self.__authors = [] + self.__publication_date = None + self.__skip_hours = None + self.__skip_days = None + self.__web_master = None self.__feed_url = None ## ITunes tags # http://www.apple.com/itunes/podcasts/specs.html#rss - self.__withhold_from_itunes = False + self.withhold_from_itunes = False + """Prevent the entire podcast from appearing in the iTunes podcast + directory. + + Note that this will affect more than iTunes, since most podcatchers use + the iTunes catalogue to implement the search feature. Listeners will + still be able to subscribe by adding the feed's address manually. + + If you don't intend on submitting this podcast to iTunes, you can set + this to True as a way of showing iTunes the middle finger (and prevent + others from submitting it as well). + + Set it to ``True`` to withhold the entire podcast from iTunes. It is set + to ``False`` by default, of course. + + :type: bool""" + self.__category = None + self.__image = None + self.__complete = None - self.__new_feed_url = None + + self.new_feed_url = None + """When set, tell iTunes that your feed has moved to this URL. + + 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. + + .. warning:: + + iTunes supports this mechanic of changing your feed's location. + However, you cannot assume the same of everyone else who has + subscribed to this podcast. Therefore, you should NEVER stop + supporting an old location for your podcast. Instead, you should + create HTTP redirects so those with the old address are redirected + to your new address, and keep those redirects up for all eternity. + + .. warning:: + + Make sure the new URL here is correct, or else you're making + people switch to a URL that doesn't work! + """ + self.__owner = None - self.__subtitle = None + + self.subtitle = None + """The subtitle for your podcast, shown mainly as a very short + description on iTunes. The subtitle displays best if it is only a few + words long, like a short slogan.""" @property def episodes(self): @@ -206,21 +320,21 @@ def _create_rss(self): feed = etree.Element('rss', version='2.0', nsmap=nsmap ) channel = etree.SubElement(feed, 'channel') - if not (self.__name and self.__website and self.__description - and self.__explicit is not None): - missing = ', '.join(([] if self.__name else ['title']) + - ([] if self.__website else ['link']) + - ([] if self.__description else ['description']) + - ([] if self.__explicit else ['itunes_explicit'])) + if not (self.name and self.website and self.description + and self.explicit is not None): + missing = ', '.join(([] if self.name else ['title']) + + ([] if self.website else ['link']) + + ([] if self.description else ['description']) + + ([] if self.explicit else ['itunes_explicit'])) raise ValueError('Required fields not set (%s)' % missing) title = etree.SubElement(channel, 'title') - title.text = self.__name + title.text = self.name link = etree.SubElement(channel, 'link') - link.text = self.__website + link.text = self.website desc = etree.SubElement(channel, 'description') - desc.text = self.__description + desc.text = self.description explicit = etree.SubElement(channel, '{%s}explicit' % ITUNES_NS) - explicit.text = "yes" if self.__explicit else "no" + explicit.text = "yes" if self.explicit else "no" if self.__cloud: cloud = etree.SubElement(channel, 'cloud') @@ -230,39 +344,39 @@ def _create_rss(self): cloud.attrib['registerProcedure'] = self.__cloud.get( 'registerProcedure') cloud.attrib['protocol'] = self.__cloud.get('protocol') - if self.__copyright: + if self.copyright: copyright = etree.SubElement(channel, 'copyright') - copyright.text = self.__copyright + copyright.text = self.copyright if self.__docs: docs = etree.SubElement(channel, 'docs') docs.text = self.__docs - if self.__generator: + if self.generator: generator = etree.SubElement(channel, 'generator') - generator.text = self.__generator - if self.__language: + generator.text = self.generator + if self.language: language = etree.SubElement(channel, 'language') - language.text = self.__language + language.text = self.language - if self.__last_updated is None: + if self.last_updated is None: lastBuildDateDate = datetime.now(dateutil.tz.tzutc()) else: - lastBuildDateDate = self.__last_updated + lastBuildDateDate = self.last_updated if lastBuildDateDate: lastBuildDate = etree.SubElement(channel, 'lastBuildDate') lastBuildDate.text = formatRFC2822(lastBuildDateDate) - if self.__authors: - authors_with_name = [a.name for a in self.__authors if a.name] + if self.authors: + authors_with_name = [a.name for a in self.authors if a.name] if authors_with_name: # We have something to display as itunes:author, combine all # names itunes_author = \ etree.SubElement(channel, '{%s}author' % ITUNES_NS) itunes_author.text = listToHumanreadableStr(authors_with_name) - if len(self.__authors) > 1 or not self.__authors[0].email: + if len(self.authors) > 1 or not self.authors[0].email: # Use dc:creator, since it supports multiple authors (and # author without email) - for a in self.__authors or []: + for a in self.authors or []: author = etree.SubElement(channel, '{%s}creator' % nsmap['dc']) if a.name and a.email: @@ -274,9 +388,9 @@ def _create_rss(self): else: # Only one author and with email, so use rss managingEditor author = etree.SubElement(channel, 'managingEditor') - author.text = str(self.__authors[0]) + author.text = str(self.authors[0]) - if self.__publication_date is None: + if self.publication_date is None: episode_dates = [e.publication_date() for e in self.episodes if e.publication_date() is not None] if episode_dates: @@ -284,66 +398,66 @@ def _create_rss(self): else: actual_pubDate = None else: - actual_pubDate = self.__publication_date + actual_pubDate = self.publication_date if actual_pubDate: pubDate = etree.SubElement(channel, 'pubDate') pubDate.text = formatRFC2822(actual_pubDate) - if self.__skip_hours: + if self.skip_hours: skipHours = etree.SubElement(channel, 'skipHours') - for h in self.__skip_hours: + for h in self.skip_hours: hour = etree.SubElement(skipHours, 'hour') hour.text = str(h) - if self.__skip_days: + if self.skip_days: skipDays = etree.SubElement(channel, 'skipDays') - for d in self.__skip_days: + for d in self.skip_days: day = etree.SubElement(skipDays, 'day') day.text = d - if self.__web_master: - if not self.__web_master.email: + if self.web_master: + if not self.web_master.email: raise RuntimeError("webMaster must have an email. Did you " "set email to None after assigning that " "Person to webMaster?") webMaster = etree.SubElement(channel, 'webMaster') - webMaster.text = str(self.__web_master) + webMaster.text = str(self.web_master) - if self.__withhold_from_itunes: + if self.withhold_from_itunes: block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) block.text = 'Yes' - if self.__category: + if self.category: category = etree.SubElement(channel, '{%s}category' % ITUNES_NS) - category.attrib['text'] = self.__category.category - if self.__category.subcategory: + category.attrib['text'] = self.category.category + if self.category.subcategory: subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) - subcategory.attrib['text'] = self.__category.subcategory + subcategory.attrib['text'] = self.category.subcategory - if self.__image: + if self.image: image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) - image.attrib['href'] = self.__image + image.attrib['href'] = self.image - if self.__complete in ('yes', 'no'): + if self.complete: complete = etree.SubElement(channel, '{%s}complete' % ITUNES_NS) - complete.text = self.__complete + complete.text = "Yes" - if self.__new_feed_url: + if self.new_feed_url: new_feed_url = etree.SubElement(channel, '{%s}new-feed-url' % ITUNES_NS) - new_feed_url.text = self.__new_feed_url + new_feed_url.text = self.new_feed_url - if self.__owner: + if self.owner: owner = etree.SubElement(channel, '{%s}owner' % ITUNES_NS) owner_name = etree.SubElement(owner, '{%s}name' % ITUNES_NS) - owner_name.text = self.__owner.name + owner_name.text = self.owner.name owner_email = etree.SubElement(owner, '{%s}email' % ITUNES_NS) - owner_email.text = self.__owner.email + owner_email.text = self.owner.email - if self.__subtitle: + if self.subtitle: subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) - subtitle.text = self.__subtitle + subtitle.text = self.subtitle - if self.__feed_url: + if self.feed_url: link_to_self = etree.SubElement(channel, '{%s}link' % nsmap['atom']) - link_to_self.attrib['href'] = self.__feed_url + link_to_self.attrib['href'] = self.feed_url link_to_self.attrib['rel'] = 'self' link_to_self.attrib['type'] = 'application/rss+xml' @@ -401,116 +515,104 @@ def rss_file(self, filename, minimize=False, doc.write(filename, pretty_print=not minimize, encoding=encoding, xml_declaration=xml_declaration) - - def name(self, name=None): - """Get or set the name of the podcast. It should be a human - readable title. Often the same as the title of the - associated website. This is mandatory for RSS and must - not be blank. - - This will set rss:title. - - :param name: The new name of the podcast. - :type name: str - :returns: The podcast's name. - """ - if not name is None: - self.__name = name - return self.__name - - - def last_updated(self, last_updated=None): - """Set or get the updated value which indicates the last time the feed - was modified in a significant way. Most often, it is taken to mean the - last time the feed was generated, which is why it defaults to the - time and date at which the RSS is generated, if set to None. + @property + def last_updated(self): + """The last time the feed was modified in a significant way. Most often, + it is taken to mean the last time the feed was generated, which is why + it defaults to the time and date at which the RSS is generated, if set + to None. The default should be sufficient for most, if not all, use + cases. 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 rss:lastBuildDate. - - Default value - If not set, updated has as value the current date and time. + This corresponds to rss:lastBuildDate. Set this to False to have no + lastBuildDate element in the feed (and thus suppress the default). - Set this to False to have no updated value in the feed. - - :param last_updated: The modification date. - :type last_updated: str or datetime.datetime - :returns: Modification date as datetime.datetime + :type: :obj:`str`, :class:`datetime.datetime` or :obj:`None`. """ - if not last_updated is None: - if last_updated is False: - self.__last_updated = False - else: - if isinstance(last_updated, string_types): - last_updated = dateutil.parser.parse(last_updated) - if not isinstance(last_updated, datetime): - raise ValueError('Invalid datetime format') - if last_updated.tzinfo is None: - raise ValueError('Datetime object has no timezone info') - self.__last_updated = last_updated - return self.__last_updated + @last_updated.setter + def last_updated(self, last_updated): + if last_updated is None or last_updated is False: + self.__last_updated = last_updated + else: + if isinstance(last_updated, string_types): + last_updated = dateutil.parser.parse(last_updated) + if not isinstance(last_updated, datetime): + raise ValueError('Invalid datetime format') + if last_updated.tzinfo is None: + raise ValueError('Datetime object has no timezone info') + self.__last_updated = last_updated - def website(self, href=None): - """Get or set this podcast's website. + @property + def cloud(self): + """The cloud data of the feed, as a 5-tuple. It specifies a web service + that supports the rssCloud interface which can be implemented in + HTTP-POST, XML-RPC or SOAP 1.1. - This corresponds to the RSS link element. + The tuple should look like this: ``(domain, port, path, registerProcedure, + protocol)``. - :param href: URI of this podcast's website. + :domain: The domain where the webservice can be found. + :port: The port the webservice listens to. + :path: The path of the webservice. + :registerProcedure: The procedure to call. + :protocol: Can be either "HTTP-POST", "xml-rpc" or "soap". Example:: - >>> p.website( href='http://example.com/') - - """ - if not href is None: - self.__website = href - return self.__website + p.cloud = ("podcast.example.org", 80, "/rpc", "cloud.notify", + "xml-rpc") + .. tip:: - def cloud(self, domain=None, port=None, path=None, registerProcedure=None, - protocol=None): - """Set or get the cloud data of the feed. 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". - :returns: Dictionary containing the cloud data. + PubSubHubbub is a competitor to rssCloud, and is the preferred + choice if you're looking to set up a new service of this kind. """ - if not domain is None: + tuple_keys = ['domain', 'port', 'path', 'registerProcedure', 'protocol'] + return tuple(self.__cloud[key] for key in tuple_keys) if self.__cloud \ + else self.__cloud + + @cloud.setter + def cloud(self, cloud): + if cloud is not None: + try: + domain, port, path, registerProcedure, protocol = cloud + except ValueError: + raise TypeError("Value of cloud must either be None or a " + "5-tuple.") if not (domain and (port != False) and path and registerProcedure and protocol): raise ValueError("All parameters of cloud must be present and" " not empty.") self.__cloud = {'domain':domain, 'port':port, 'path':path, 'registerProcedure':registerProcedure, 'protocol':protocol} - return self.__cloud - + else: + self.__cloud = None - def generator(self, generator=None, version=None, uri=None, + def set_generator(self, generator=None, version=None, uri=None, exclude_feedgen=False): - """Get or set the generator of the feed, which identifies the software - used to generate the feed, for debugging and other purposes. + """Set the generator of the feed, formatted nicely, which identifies the + software used to generate the feed, for debugging and other purposes. :param generator: Software used to create the feed. :param version: (Optional) Version of the software, as a tuple. :param uri: (Optional) URI the software can be found. :param exclude_feedgen: (Optional) Set to True to disable the mentioning of the python-feedgen library. + + .. seealso:: + + The attribute :py:attr:`.generator` + Lets you access and set the generator string yourself, without + any formatting help. """ - if not generator is None: - self.__generator = self._program_name_to_str(generator, version, uri) + \ - (" (using %s)" % self._feedgen_generator_str - if not exclude_feedgen else "") - return self.__generator + self.generator = self._program_name_to_str(generator, version, uri) + \ + (" (using %s)" % self._feedgen_generator_str + if not exclude_feedgen else "") def _program_name_to_str(self, generator=None, version=None, uri=None): return generator + \ @@ -525,65 +627,10 @@ def _feedgen_generator_str(self): feedgen.version.website ) - def copyright(self, copyright=None): - """Get or set the copyright notice for content in this podcast. - - This should be human-readable. For example, "Copyright 2016 Example - Radio". - - Note that even if you leave out the copyright notice, your content is - still protected by copyright (unless anything else is indicated), since - you do not need a copyright statement for something to be protected by - copyright. If you intend to put the podcast in public domain or license - it under a Creative Commons license, you should say so in the copyright - notice. - - :param copyright: The copyright notice. - :type copyright: str - :returns: The copyright notice. - """ - - if not copyright is None: - self.__copyright = copyright - return self.__copyright - - - def description(self, description=None): - """Set and get the description of the feed, - which is a phrase or sentence describing the channel. It is mandatory for - RSS feeds, and is shown under the podcast's name on the iTunes store - page. - - :param description: Description of the podcast. - :returns: Description of the podcast. - - """ - if not description is None: - self.__description = description - return self.__description - - - def language(self, language=None): - """Get or set the language of the podcast. - - This allows aggregators to group all Italian - language podcasts, for example, on a single page. - - :param language: The language of the podcast. It must be a two-letter - code, as found in ISO639-1, with the - possibility of specifying subcodes (eg. en-US for American English). - See http://www.rssboard.org/rss-language-codes and - http://www.loc.gov/standards/iso639-2/php/code_list.php - :returns: Language of the feed. - """ - if not language is None: - self.__language = language - return self.__language - @property def authors(self): - """List of :class:`~feedgen.person.Person` that are responsible for this + """List of :class:`~feedgen.Person` that are responsible for this podcast's editorial content. Any value you assign to authors will be automatically converted to a @@ -622,150 +669,124 @@ def authors(self, authors): "%s given. You must put your object in a list, " "even if there's only one author." % authors) - - def publication_date(self, publication_date=None): + @property + def publication_date(self): """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. - - Default value - If not set, published will use the value of the episode with the - latest publication date (which may be in the future). If there - are no episodes, the publication date is omitted from the feed. - - If you want to omit the publication date from the feed, set pubDate - to False. - - :param publication_date: The publication date. - :returns: Publication date as datetime.datetime + date flips once every 24 hours. That's when the publication date of the + channel changes. + + :type: None, a string which will automatically be parsed or a + datetime.datetime object. In any case it is necessary that the value + include timezone information. + :Default value: If this is None when the feed is generated, the + publication date of the episode with the latest publication date (which + may be in the future) is used. If there are no episodes, the publication + date is omitted from the feed. + + If you want to forcefully omit the publication date from the feed, set + this to ``False``. """ - if not publication_date is None: + return self.__publication_date + + @publication_date.setter + def publication_date(self, publication_date): + if publication_date is not None and publication_date is not False: if isinstance(publication_date, string_types): publication_date = dateutil.parser.parse(publication_date) - if publication_date is not False and not isinstance(publication_date, datetime): + if not isinstance(publication_date, datetime): raise ValueError('Invalid datetime format') - elif publication_date is not False and publication_date.tzinfo is None: + elif publication_date.tzinfo is None: raise ValueError('Datetime object has no timezone info') - self.__publication_date = publication_date - - return self.__publication_date + self.__publication_date = publication_date + @property + def skip_hours(self): + """Set of hours in which feed readers don't need to refresh this feed. - def skip_hours(self, hours=None, replace=False): - """Set or get which hours feed readers don't need to refresh this feed. - - This method can be called with an hour or a list of hours. The hours are - represented as integer values from 0 to 23. When called multiple times, - the new hours are added to the list of existing hours, unless replace - is True. + The hours are represented as integer values from 0 to 23. For example, to skip hours between 18 and 7:: >>> from feedgen import Podcast >>> p = Podcast() - >>> p.skip_hours(range(18, 24)) + >>> p.skip_hours = set(range(18, 24)) + >>> p.skip_hours {18, 19, 20, 21, 22, 23} - >>> p.skip_hours(range(8)) + >>> p.skip_hours |= set(range(8)) + >>> p.skip_hours {0, 1, 2, 3, 4, 5, 6, 7, 18, 19, 20, 21, 22, 23} - - :param hours: List of hours the feedreaders should not check the feed. - :type hours: list or set or int - :param replace: Add or replace old data. - :returns: Set of hours the feedreaders should not check the feed. """ - if not hours is None: + return self.__skip_hours + + @skip_hours.setter + def skip_hours(self, hours): + if hours is not None: if not (isinstance(hours, list) or isinstance(hours, set)): - hours = [hours] + hours = set(hours) for h in hours: - if not h in range(24): + if h not in range(24): raise ValueError('Invalid hour %s' % h) - if replace or not self.__skip_hours: - self.__skip_hours = set() - self.__skip_hours |= set(hours) - return self.__skip_hours + self.__skip_hours = hours + + @property + def skip_days(self): + """Set of days in which podcatchers don't need to refresh this feed. + The days are represented using strings of their dayname, like "Monday" + or "wednesday". - def skip_days(self, days=None, replace=False): - """Set or get the value of skipDays, a hint for aggregators telling them - which days they can skip. + For example, to skip the weekend:: - 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'. + >>> from feedgen import Podcast + >>> p = Podcast() + >>> p.skip_days = {"Friday", "Saturday", "sunday"} + >>> p.skip_days + {"Saturday", "Friday", "Sunday"} - :param days: List of days the feedreaders should not check the feed. - :type days: list or set or str - :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' % d) - if replace or not self.__skip_days: - self.__skip_days = set() - self.__skip_days |= set(days) return self.__skip_days + @skip_days.setter + def skip_days(self, days): + if days is not None: + if not isinstance(days, set): + days = set(days) + for d in days: + if not d.lower() in ['monday', 'tuesday', 'wednesday', 'thursday', + 'friday', 'saturday', 'sunday']: + raise ValueError('Invalid day %s' % d) + self.__skip_days = {day.capitalize() for day in days} + else: + self.__skip_days = None - def web_master(self, web_master=None): - """Get and set the :class:`~feedgen.person.Person` responsible for + @property + def web_master(self): + """The :class:`~feedgen.Person` responsible for technical issues relating to the feed. - - :param web_master: The person responsible for technical issues relating - to the feed. This instance of Person must have its email set. - :type web_master: Person - :returns: The person responsible for technical issues relating to the - feed. """ + return self.__web_master + + @web_master.setter + def web_master(self, web_master): if web_master is not None: if (not hasattr(web_master, "email")) or not web_master.email: raise ValueError("The webmaster must have an email attribute " "and it must be set and not empty.") - self.__web_master = web_master - return self.__web_master - - def withhold_from_itunes(self, withhold_from_itunes=None): - """Get or set the iTunes block attribute. Use this to prevent the entire - podcast from appearing in the iTunes podcast directory. - - Note that this will affect more than iTunes, since most podcatchers use - the iTunes catalogue to implement the search feature. Listeners will - still be able to subscribe by adding the feed's address manually. - - If you don't intend on submitting this podcast to iTunes, you can set - this to True as a way of showing iTunes the middle finger (and prevent - others from submitting it as well). - - Set it to ``True`` to withhold the entire podcast from iTunes. It is set - to ``False`` by default, of course. - - :param withhold_from_itunes: ``True`` to block the podcast from iTunes. - :type withhold_from_itunes: bool or None - :returns: If the podcast is blocked. - """ - if not withhold_from_itunes is None: - self.__withhold_from_itunes = withhold_from_itunes - return self.__withhold_from_itunes + self.__web_master = web_master - def category(self, category=None): - """Get or set the iTunes category, which appears in the category column + @property + def category(self): + """The iTunes category, which appears in the category column and in iTunes Store Browser. - Use the :class:`feedgen.category.Category` class. - - :param category: This podcast's category. - :type category: feedgen.category.Category or None - :returns: This podcast's category. + Use the :class:`feedgen.Category` class. """ - if not category is None: + return self.__category + + @category.setter + def category(self, category): + if category is not None: # Check that the category quacks like a duck if hasattr(category, "category") and \ hasattr(category, "subcategory"): @@ -773,11 +794,12 @@ def category(self, category=None): else: raise TypeError("A Category(-like) object must be used, got " "%s" % category) - return self.__category - + else: + self.__category = None - def image(self, image=None): - """Get or set the image for the podcast. This tag specifies the artwork + @property + def image(self): + """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 @@ -792,50 +814,28 @@ def image(self, image=None): 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 image: Image of the podcast. - :type image: str - :returns: Image of the podcast. + requests for iTunes to be able to automatically update your cover art. """ - if not image is None: + return self.__image + + @image.setter + def image(self, image): + if image is not None: lowercase_itunes_image = image.lower() if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not .%s' % image.split(".")[-1]) + raise ValueError('Image filename must end with png or jpg, not ' + '.%s' % image.split(".")[-1]) self.__image = image - return self.__image - - def explicit(self, explicit=None): - """Get or set whether this podcast may be inappropriate for children or - not. - - This is one of the mandatory attributes, and can seen as the - default for episodes. Individual episodes can be marked as explicit - or clean independently from the podcast. - - If you set this to ``True``, an "explicit" parental advisory - graphic will appear next to your podcast artwork on the iTunes Store and - in the Name column in iTunes. If it is set to ``False``, - 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. - - :param explicit: True if explicit, False if not. - :type explicit: bool or None - :returns: Whether the podcast contains explicit material or not. - """ - if not explicit is None: - self.__explicit = explicit - return self.__explicit + else: + self.__image = None - def complete(self, complete=None): - """Get or set the itunes:complete value of the podcast. This tag can be - used to indicate the completion of a podcast. + @property + def complete(self): + """Whether this podcast is completed or not. - 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. + If you set this to ``True``, you are indicating that no more + episodes will be added to the podcast. If you let this be ``None`` or + ``False``, you are indicating that new episodes may be posted. .. warning:: @@ -844,93 +844,49 @@ def complete(self, complete=None): there's any chance at all that a new episode will be released someday. - :param complete: If the podcast is complete. - :type complete: bool or str - :returns: If the podcast is complete. """ - if not complete is None: - if not complete in ('yes', 'no', '', True, False): - raise ValueError('Invalid value "%s" for complete tag' % complete) - if complete == True: - complete = 'yes' - if complete == False: - complete = 'no' - self.__complete = complete return self.__complete - def new_feed_url(self, new_feed_url=None): - """Get or set the itunes-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. - - .. warning:: - - iTunes supports this mechanic of changing your feed's location. - However, you cannot assume the same of everyone else who has - subscribed to this podcast. Therefore, you should NEVER stop - supporting an old location for your podcast. Instead, you should - create redirects so those with the old address are redirected to - your new address, and keep those up for all eternity. - - .. warning:: - - Make sure the new URL here is correct, or else you're making - people switch to a URL that doesn't work! - - :param new_feed_url: New feed URL. - :type new_feed_url: str - :returns: New feed URL. - """ - if not new_feed_url is None: - self.__new_feed_url = new_feed_url - return self.__new_feed_url + @complete.setter + def complete(self, complete): + if complete is not None: + self.__complete = bool(complete) + else: + self.__complete = None - def owner(self, owner): - """Get or set the owner of the podcast. This tag contains - information that iTunes will use to contact the owner of the podcast for + @property + def owner(self): + """The :class:`~feedgen.Person` who owns this podcast. iTunes + will use this information to contact the owner of the podcast for communication specifically about the podcast. It will not be publicly displayed, but it will be in the feed source. Both the name and email are required. - - :param owner: The :class:`~feedgen.person.Person` which iTunes will - contact when needed. - :returns: The owner of this feed, which iTunes will contact when needed. """ + return self.__owner + + @owner.setter + def owner(self, owner): if owner is not None: if owner.name and owner.email: self.__owner = owner else: raise ValueError('Both name and email must be set.') - return self.__owner - - def subtitle(self, 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 subtitle: Subtitle of the podcast. - :type subtitle: str - :returns: Subtitle of the podcast. - """ - if not subtitle is None: - self.__subtitle = subtitle - return self.__subtitle + else: + self.__owner = None - def feed_url(self, feed_url=None): - """Get or set the URL which this feed is available at. + @property + def feed_url(self): + """The URL which this feed is available at. Identifying a feed's URL within the feed makes it more portable, self-contained, and easier to cache. You should therefore set this - property, if you're able to. - - :param feed_url: The URL at which you can access this feed. - :type feed_url: str - :returns: The URL at which you can access this feed. + attribute if you're able to. """ + return self.__feed_url + + @feed_url.setter + def feed_url(self, feed_url): if feed_url is not None: if feed_url and not feed_url.startswith(( 'http://', @@ -940,6 +896,5 @@ def feed_url(self, feed_url=None): raise ValueError("The feed url must be a valid URL, but it " "doesn't have a valid URL scheme " "(like for example http:// or https://)") - self.__feed_url = feed_url - return self.__feed_url + self.__feed_url = feed_url diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 8b50687..7e64195 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -27,10 +27,10 @@ def setUp(self): self.description = 'A cool tent' self.explicit = False - fg.name(self.title) - fg.website(self.link) - fg.description(self.description) - fg.explicit(self.explicit) + fg.name = self.title + fg.website = self.link + fg.description = self.description + fg.explicit = self.explicit fe = fg.add_episode() fe.id('http://lernfunk.de/media/654321/1') @@ -138,7 +138,7 @@ def test_feedPubDateNotOverriddenByEpisode(self): assert parsedate(pubDate.text) == self.fg.episodes[0].publication_date() new_date = datetime.datetime(2016, 1, 2, 3, 4, tzinfo=pytz.utc) - self.fg.publication_date(new_date) + self.fg.publication_date = new_date pubDate = self.fg._create_rss().find("channel").find("pubDate") # Now it uses the custom-set date assert pubDate is not None @@ -148,7 +148,7 @@ def test_feedPubDateDisabled(self): self.fg.episodes[0].publication_date( datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) ) - self.fg.publication_date(False) + self.fg.publication_date = False pubDate = self.fg._create_rss().find("channel").find("pubDate") assert pubDate is None # Not found! diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 2e9fa44..fbe8469 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -45,8 +45,8 @@ def setUp(self): self.contributor = {'name':"Contributor Name", 'email': 'Contributor email'} self.copyright = "The copyright notice" self.docs = 'http://www.rssboard.org/rss-specification' - self.skipDays = 'Tuesday' - self.skipHours = 23 + self.skipDays = {'Tuesday'} + self.skipHours = {23} self.explicit = False @@ -54,20 +54,19 @@ def setUp(self): self.webMaster = Person(email='webmaster@example.com') - fg.name(self.title) - fg.website(href=self.linkHref) - fg.description(self.description) - fg.language(self.language) - fg.cloud(domain=self.cloudDomain, port=self.cloudPort, - path=self.cloudPath, registerProcedure=self.cloudRegisterProcedure, - protocol=self.cloudProtocol) - fg.copyright(self.copyright) + fg.name = self.title + fg.website = self.linkHref + fg.description = self.description + fg.language = self.language + fg.cloud = (self.cloudDomain, self.cloudPort, self.cloudPath, + self.cloudRegisterProcedure, self.cloudProtocol) + fg.copyright = self.copyright fg.authors.append(self.author) - fg.skip_days(self.skipDays) - fg.skip_hours(self.skipHours) - fg.web_master(self.webMaster) - fg.feed_url(self.feedUrl) - fg.explicit(self.explicit) + fg.skip_days = self.skipDays + fg.skip_hours = self.skipHours + fg.web_master = self.webMaster + fg.feed_url = self.feedUrl + fg.explicit = self.explicit self.fg = fg @@ -75,17 +74,17 @@ def setUp(self): def test_baseFeed(self): fg = self.fg - assert fg.name() == self.title + assert fg.name == self.title assert fg.authors[0] == self.author - assert fg.web_master() == self.webMaster + assert fg.web_master == self.webMaster - assert fg.website() == self.linkHref + assert fg.website == self.linkHref - assert fg.description() == self.description + assert fg.description == self.description - assert fg.language() == self.language - assert fg.feed_url() == self.feedUrl + assert fg.language == self.language + assert fg.feed_url == self.feedUrl def test_rssFeedFile(self): @@ -126,8 +125,8 @@ def checkRssString(self, rssString): assert channel.find("copyright").text == self.copyright assert channel.find("docs").text == self.docs assert self.author.email in channel.find("managingEditor").text - assert channel.find("skipDays").find("day").text == self.skipDays - assert int(channel.find("skipHours").find("hour").text) == self.skipHours + assert channel.find("skipDays").find("day").text in self.skipDays + assert int(channel.find("skipHours").find("hour").text) in self.skipHours assert self.webMaster.email in channel.find("webMaster").text assert channel.find("{%s}link" % nsAtom).get('href') == self.feedUrl assert channel.find("{%s}link" % nsAtom).get('rel') == 'self' @@ -135,17 +134,37 @@ def checkRssString(self, rssString): 'application/rss+xml' def test_feedUrlValidation(self): - self.assertRaises(ValueError, self.fg.feed_url, "example.com/feed.rss") + self.assertRaises(ValueError, setattr, self.fg, "feed_url", + "example.com/feed.rss") def test_generator(self): software_name = "My Awesome Software" - self.fg.generator(software_name) + software_version = (1, 0) + software_url = "http://example.com/awesomesoft/" + + # Using set_generator, text includes python-feedgen + self.fg.set_generator(software_name) rss = self.fg._create_rss() generator = rss.find("channel").find("generator").text assert software_name in generator assert self.programname in generator - self.fg.generator(software_name, exclude_feedgen=True) + # Using set_generator, text excludes python-feedgen + self.fg.set_generator(software_name, exclude_feedgen=True) + generator = self.fg._create_rss().find("channel").find("generator").text + assert software_name in generator + assert self.programname not in generator + + # Using set_generator, text includes name, version and url + self.fg.set_generator(software_name, software_version, software_url) + generator = self.fg._create_rss().find("channel").find("generator").text + assert software_name in generator + assert str(software_version[0]) in generator + assert str(software_version[1]) in generator + assert software_url in generator + + # Using generator directly, text excludes python-feedgen + self.fg.generator = software_name generator = self.fg._create_rss().find("channel").find("generator").text assert software_name in generator assert self.programname not in generator @@ -167,13 +186,13 @@ def getLastBuildDateElement(fg): assert getLastBuildDateElement(self.fg) is not None # Test that it respects my custom value - self.fg.last_updated(date) + self.fg.last_updated = date lastBuildDate = getLastBuildDateElement(self.fg) assert lastBuildDate is not None assert dateutil.parser.parse(lastBuildDate.text) == date # Test that it is left out when set to False - self.fg.last_updated(False) + self.fg.last_updated = False lastBuildDate = getLastBuildDateElement(self.fg) assert lastBuildDate is None @@ -257,23 +276,23 @@ def do_authorsInvalidValue(self): def test_webMaster(self): - self.fg.web_master(Person(None, "justan@email.address")) + self.fg.web_master = Person(None, "justan@email.address") channel = self.fg._create_rss().find("channel") - assert channel.find("webMaster").text == self.fg.web_master().email + assert channel.find("webMaster").text == self.fg.web_master.email - self.assertRaises(ValueError, self.fg.web_master, + self.assertRaises(ValueError, setattr, self.fg, "web_master", Person("Mr. No Email Address")) - self.fg.web_master(Person("Both a name", "and_an@email.com")) + self.fg.web_master = Person("Both a name", "and_an@email.com") channel = self.fg._create_rss().find("channel") # Does webMaster follow the pattern "email (name)"? - self.assertEqual(self.fg.web_master().email + - " (" + self.fg.web_master().name + ")", + self.assertEqual(self.fg.web_master.email + + " (" + self.fg.web_master.name + ")", channel.find("webMaster").text) def test_categoryWithoutSubcategory(self): c = Category("Arts") - self.fg.category(c) + self.fg.category = c channel = self.fg._create_rss().find("channel") itunes_category = channel.find("{%s}category" % self.nsItunes) assert itunes_category is not None @@ -284,7 +303,7 @@ def test_categoryWithoutSubcategory(self): def test_categoryWithSubcategory(self): c = Category("Arts", "Food") - self.fg.category(c) + self.fg.category = c channel = self.fg._create_rss().find("channel") itunes_category = channel.find("{%s}category" % self.nsItunes) assert itunes_category is not None @@ -295,10 +314,10 @@ def test_categoryWithSubcategory(self): def test_categoryChecks(self): c = ("Arts", "Food") - self.assertRaises(TypeError, self.fg.category, c) + self.assertRaises(TypeError, setattr, self.fg, "category", c) def test_explicitIsExplicit(self): - self.fg.explicit(True) + self.fg.explicit = True channel = self.fg._create_rss().find("channel") itunes_explicit = channel.find("{%s}explicit" % self.nsItunes) assert itunes_explicit is not None @@ -307,7 +326,7 @@ def test_explicitIsExplicit(self): % itunes_explicit.text def test_explicitIsClean(self): - self.fg.explicit(False) + self.fg.explicit = False channel = self.fg._create_rss().find("channel") itunes_explicit = channel.find("{%s}explicit" % self.nsItunes) assert itunes_explicit is not None @@ -329,13 +348,13 @@ def test_mandatoryValues(self): for test_property in mandatory_properties: fg = Podcast() if test_property != "description": - fg.description(self.description) + fg.description = self.description if test_property != "title": - fg.name(self.title) + fg.name = self.title if test_property != "link": - fg.website(self.linkHref) + fg.website = self.linkHref if test_property != "explicit": - fg.explicit(self.explicit) + fg.explicit = self.explicit try: self.assertRaises(ValueError, fg._create_rss) except AssertionError as e: @@ -343,16 +362,16 @@ def test_mandatoryValues(self): from e def test_withholdFromItunesOffByDefault(self): - assert not self.fg.withhold_from_itunes() + assert not self.fg.withhold_from_itunes def test_withholdFromItunes(self): - self.fg.withhold_from_itunes(True) + self.fg.withhold_from_itunes = True itunes_block = self.fg._create_rss().find("channel")\ .find("{%s}block" % self.nsItunes) assert itunes_block is not None self.assertEqual(itunes_block.text.lower(), "yes") - self.fg.withhold_from_itunes(False) + self.fg.withhold_from_itunes = False itunes_block = self.fg._create_rss().find("channel")\ .find("{%s}block" % self.nsItunes) assert itunes_block is None From 7e28d93d1cafc5da77ecb8c0a2e0d3aab1b1e586 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 5 Jul 2016 22:08:16 +0200 Subject: [PATCH 080/200] Change from setters/getters to attributes in BaseEpisode --- doc/user/basic_usage_guide/part_2.rst | 47 ++- feedgen/__init__.py | 1 + feedgen/__main__.py | 16 +- feedgen/episode.py | 430 ++++++++++++-------------- feedgen/podcast.py | 4 +- feedgen/tests/test_entry.py | 83 +++-- 6 files changed, 268 insertions(+), 313 deletions(-) diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index c024e16..391a2a4 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -3,16 +3,16 @@ Adding episodes --------------- To add episodes to a feed, you need to create new -:class:`feedgen.episode.Episode` objects and +:class:`feedgen.Episode` objects and append them to the list of entries in the Podcast. That is pretty straight-forward:: - from feedgen.episode import Episode + from feedgen import Episode my_episode = Episode() p.episodes.append(my_episode) There is a convenience method called :meth:`Podcast.add_episode ` -which optionally creates a new instance of :class:`~feedgen.episode.Episode`, adds it to the podcast +which optionally creates a new instance of :class:`~feedgen.Episode`, adds it to the podcast and returns it, allowing you to assign it to a variable:: my_episode = p.add_episode() @@ -40,14 +40,12 @@ well as a short subtitle:: "all.
Today's intro music: " + \ "Example Song" -They're all pretty obvious: +Read more: -.. autosummary:: - - ~feedgen.BaseEpisode.title - ~feedgen.BaseEpisode.subtitle - ~feedgen.BaseEpisode.summary - ~feedgen.BaseEpisode.long_summary +* :attr:`~feedgen.BaseEpisode.title` +* :attr:`~feedgen.BaseEpisode.subtitle` +* :attr:`~feedgen.BaseEpisode.summary` +* :attr:`~feedgen.BaseEpisode.long_summary` Enclosing media @@ -67,7 +65,7 @@ attached to it! :: Normally, you must specify how big the **file size** is in bytes (and the MIME type, if the file extension is unknown to iTunes), but PodcastGenerator can send a HEAD request to the URL and retrieve the missing information. This is -done by calling :meth:`Media.create_from_server_response ` +done by calling :meth:`Media.create_from_server_response ` instead of using the constructor directly. You must pass in the `requests `_ module, so it must be installed! :: @@ -91,10 +89,10 @@ The **duration** is also important to include, for your listeners' convenience. Without it, they won't know how long an episode is before they start downloading and listening. -.. autosummary:: +Read more about: - ~feedgen.BaseEpisode.media - ~feedgen.media.Media +* :attr:`feedgen.BaseEpisode.media` (the attribute) +* :class:`feedgen.Media` (the class which you use as value) Identifying the episode @@ -111,8 +109,7 @@ That is, given the example above, the id of ``my_episode`` would be An episode's ID should never change. Therefore, **if you don't set id, the media URL must never change either**. -.. autosummary:: ~feedgen.BaseEpisode.id - +Read more about :attr:`the id attribute `. Episode's publication date ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -135,7 +132,7 @@ will get a new episode which appears to have existed for longer than it has. my_episode.publication_date = datetime.datetime(2016, 5, 18, 10, 0, tzinfo=pytz.utc) -.. autosummary:: ~feedgen.BaseEpisode.publication_date +Read more about :attr:`the publication_date attribute `. The Link @@ -154,7 +151,7 @@ the link. :: If you don't have anything to link to, then that's fine as well. No link is better than a disappointing link. -.. autosummary:: ~feedgen.BaseEpisode.link +Read more about :attr:`the link attribute `. The Authors @@ -181,7 +178,7 @@ You can even have multiple authors:: my_episode.authors = [Person("Joe Bob"), Person("Alice Bob")] -.. autosummary:: ~feedgen.BaseEpisode.authors +Read more about :attr:`an episode's authors `. Less used attributes @@ -196,12 +193,12 @@ Less used attributes # Be careful about using the following attribute! my_episode.withhold_from_itunes = True -.. autosummary:: +More details: - ~feedgen.BaseEpisode.image - ~feedgen.BaseEpisode.explicit - ~feedgen.BaseEpisode.is_closed_captioned - ~feedgen.BaseEpisode.position - ~feedgen.BaseEpisode.withhold_from_itunes +* :attr:`~feedgen.BaseEpisode.image` +* :attr:`~feedgen.BaseEpisode.explicit` +* :attr:`~feedgen.BaseEpisode.is_closed_captioned` +* :attr:`~feedgen.BaseEpisode.position` +* :attr:`~feedgen.BaseEpisode.withhold_from_itunes` The final step is :doc:`part_3` diff --git a/feedgen/__init__.py b/feedgen/__init__.py index c0dfb11..c3a8325 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -5,3 +5,4 @@ from .person import Person from .not_supported_by_itunes_warning import NotSupportedByItunesWarning from .category import Category +from .util import htmlencode diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 1fe597e..acf6f5e 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -42,7 +42,7 @@ def main(): # Remember what type of feed the user wants arg = sys.argv[1] - from feedgen import Podcast, Person, Media, Category + from feedgen import Podcast, Person, Media, Category, htmlencode # Initialize the feed p = Podcast() p.name = 'Testfeed' @@ -59,18 +59,18 @@ def main(): p.owner = Person('John Doe', 'john@example.com') e1 = p.add_episode() - e1.id('http://lernfunk.de/_MEDIAID_123#1') - e1.title('First Element') - e1.summary('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen + e1.id = 'http://lernfunk.de/_MEDIAID_123#1' + e1.title = 'First Element' + e1.summary = htmlencode('''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 <3.''', html=False) - e1.link(link='http://example.com') + verba <3.''') + e1.link = 'http://example.com' e1.authors = [Person('Lars Kiesow', 'lkiesow@uos.de')] - e1.publication_date(datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc)) - e1.media(Media("http://example.com/episodes/loremipsum.mp3", 454599964)) + e1.publication_date = datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc) + e1.media = Media("http://example.com/episodes/loremipsum.mp3", 454599964) # Should we just print out, or write to file? if arg == 'rss': diff --git a/feedgen/episode.py b/feedgen/episode.py index 8efd246..250c8f6 100644 --- a/feedgen/episode.py +++ b/feedgen/episode.py @@ -30,23 +30,93 @@ class BaseEpisode(object): def __init__(self): # RSS self.__authors = [] - self.__summary = None - self.__long_summary = None + self.summary = None + """The summary of this episode, in a format that can be parsed by + XHTML parsers. + + If your summary isn't fit to be parsed as XHTML, you can use + :py:func:`feedgen.htmlencode` to fix the text, like this:: + + >>> ep.summary = feedgen.htmlencode("We spread lots of love <3") + >>> ep.summary + We spread lots of love <3 + + In iTunes, the summary is shown in a separate window that appears when + the "circled i" in the Description column is clicked. This field can be + up to 4000 characters in length. + + See also :py:attr:`.BaseEpisode.subtitle` and + :py:attr:`.BaseEpisode.long_summary`.""" + + self.long_summary = None + """A long (read: full) summary, which supplements the shorter + :attr:`~feedgen.item.BaseEpisode.summary`. + + This attribute should be seen as a full, longer variation of + summary if summary exists. Even then, the long_summary should be + independent from summary, in that you only need to read one of them. + This means you may have to repeat the first sentences. + + If summary does not exist but this does, this is used in place of + summary.""" + self.__media = None - self.__id = None - self.__rss_link = None + + self.id = None + """This episode's globally unique identifier. + + If not present, the URL of the enclosed media is used. This is usually + the best way to go, **as long as the media URL doesn't change**. + + Set the id to boolean False if you don't want to associate any id to + this episode. + + It is important that an episode keeps the same ID until the end of time, + since the ID is used by clients to identify which episodes have been + listened to, which episodes are new, and so on. Changing the ID causes + the same consequences as deleting the existing episode and adding a + new, identical episode. + + Note that this is a GLOBALLY unique identifier. Thus, not only must it + be unique in this podcast, it must not be the same ID as any other + episode for any podcast out there. To ensure this, you should use a + domain which you own (for example, use something like + http://example.org/podcast/episode1 if you own example.org). + + This property corresponds to the RSS GUID element.""" + + self.link = None + """Get or set the link to the full version of this episode description. + Remember to start the link with the scheme, e.g. https://.""" + self.__publication_date = None - self.__title = None + + self.title = None + """This episode's human-readable title. + Title is mandatory and should not be blank.""" # ITunes tags # http://www.apple.com/itunes/podcasts/specs.html#rss self.__withhold_from_itunes = False + self.__image = None + self.__itunes_duration = None + self.__explicit = None - self.__is_closed_captioned = None + + self.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 + ``True`` and ``False``.""" + self.__position = None - self.__subtitle = None + + self.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.""" def rss_entry(self): """Create a RSS item and return it.""" @@ -56,30 +126,31 @@ def rss_entry(self): entry = etree.Element('item') - if not (self.__title or self.__summary): - raise ValueError('Required fields not set') + if not (self.title or self.summary): + raise ValueError('Required fields not set, make sure either ' + 'title or summary is set!') - if self.__title: + if self.title: title = etree.SubElement(entry, 'title') - title.text = self.__title + title.text = self.title - if self.__rss_link: + if self.link: link = etree.SubElement(entry, 'link') - link.text = self.__rss_link + link.text = self.link - if self.__summary or self.__long_summary: - if self.__summary and self.__long_summary: + if self.summary or self.long_summary: + if self.summary and self.long_summary: # Both are present, so use both content and description description = etree.SubElement(entry, 'description') - description.text = etree.CDATA(self.__summary) + description.text = etree.CDATA(self.summary) content = etree.SubElement(entry, '{%s}encoded' % 'http://purl.org/rss/1.0/modules/content/') - content.text = etree.CDATA(self.__long_summary) + content.text = etree.CDATA(self.long_summary) else: # Only one is present, use description because of support description = etree.SubElement(entry, 'description') description.text = \ - etree.CDATA(self.__summary or self.__long_summary) + etree.CDATA(self.summary or self.long_summary) if self.__authors: authors_with_name = [a.name for a in self.__authors if a.name] @@ -105,9 +176,9 @@ def rss_entry(self): author = etree.SubElement(entry, 'author') author.text = str(self.__authors[0]) - if self.__id: - rss_guid = self.__id - elif self.__media and self.__id is None: + if self.id: + rss_guid = self.id + elif self.__media and self.id is None: rss_guid = self.__media.url else: # self.__rss_guid was set to boolean False, or no enclosure @@ -140,65 +211,26 @@ def rss_entry(self): duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) duration.text = self.__itunes_duration - if self.__explicit in ('yes', 'no', 'clean'): + if self.__explicit is not None: explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) - explicit.text = self.__explicit + explicit.text = "Yes" if self.__explicit else "No" - if not self.__is_closed_captioned is None: - is_closed_captioned = etree.SubElement(entry, '{%s}isClosedCaptioned' % ITUNES_NS) - is_closed_captioned.text = 'yes' if self.__is_closed_captioned else 'no' + if self.is_closed_captioned is not None: + is_closed_captioned = etree.SubElement(entry, + '{%s}isClosedCaptioned' % ITUNES_NS) + is_closed_captioned.text = 'Yes' if self.is_closed_captioned \ + else 'No' - if not self.__position is None and self.__position >= 0: + if self.__position is not None and self.__position >= 0: order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) order.text = str(self.__position) - if self.__subtitle: + if self.subtitle: subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) - subtitle.text = self.__subtitle + subtitle.text = self.subtitle return entry - def title(self, title=None): - """Get or set this episode's human-readable title. - Title is mandatory and should not be blank. - - :param title: This new title of this episode. - :returns: This episode's title. - """ - if not title is None: - self.__title = title - return self.__title - - def id(self, new_id=None): - """Get or set this episode's globally unique identifier. - - If not present, the URL of the enclosed media is used. This is usually - the best way to go, **as long as the media URL doesn't change**. - - Set the id to boolean False if you don't want to associate any id to - this episode. - - It is important that an episode keeps the same ID until the end of time, - since the ID is used by clients to identify which episodes have been - listened to, which episodes are new, and so on. Changing the ID causes - the same consequences as deleting the existing episode and adding a - new, identical episode. - - Note that this is a GLOBALLY unique identifier. Thus, not only must it - be unique in this podcast, it must not be the same ID as any other - episode for any podcast out there. To ensure this, you should use a - domain which you own (for example, use something like - http://example.org/podcast/episode1 if you own example.org). - - This property corresponds to the RSS GUID element. - - :param new_id: Globally unique, permanent id of this episode. - :returns: Id of this episode. - """ - if not new_id is None: - self.__id = new_id - return self.__id - @property def authors(self): """List of :class:`~feedgen.person.Person` that contributed to this @@ -245,73 +277,19 @@ def authors(self, authors): "%s given. You must put your object in a list, " "even if there's only one author." % authors) - def summary(self, new_summary=None, html=True): - """Get or set the summary of this episode. - - In iTunes, the summary is shown in a separate window that appears when - the "circled i" in the Description column is clicked. This field can be - up to 4000 characters in length. - - See also :py:meth:`.BaseEpisode.itunes_subtitle`. - - :param new_summary: The summary of this episode. - :param html: Treat the summary as HTML. If set to False, the summary - will be HTML escaped (thus, any tags will be displayed in plain - text). If set to True, the tags are parsed by clients which support - HTML, but if something is not to be regarded as HTML, you must - escape it yourself using HTML entities. - :returns: Summary of this episode. - """ - if not new_summary is None: - if not html: - new_summary = htmlencode(new_summary) - self.__summary = new_summary - return self.__summary - - def long_summary(self, long_summary=None): - """A long (read: full) summary, which supplements the shorter - :attr:`~feedgen.item.BaseEpisode.summary`. - - This attribute should be seen as a full, longer variation of - summary if summary exists. Even then, the long_summary should be - independent from summary, in that you only need to read one of them. - This means you may have to repeat the first sentences. - - If summary does not exist but this does, this is used in place of - summary. - - :param long_summary: A long summary which supplements the shorter - summary. - :type long_summary: str or None - :returns: The long summary which supplements the shorter summary. - """ - if long_summary is not None: - self.__long_summary = long_summary - return self.__long_summary - - def link(self, link=None): - """Get or set the link to the full version of this episode description. - - :param link: the URI of the referenced resource (typically a Web page) - :type link: str - :returns: The current link URI. - """ - if not link is None: - self.__rss_link = link - return self.__rss_link - - def publication_date(self, publication_date=None): + @property + def publication_date(self): """Set or get the time that this episode first was made public. The value can either be a string which will automatically be parsed or a datetime.datetime object. In both cases you must ensure that the value includes timezone information. - - :param publication_date: The date this episode was first made public. - :type publication_date: datetime.datetime or str or None - :returns: Creation date as datetime.datetime """ - if not publication_date is None: + return self.__publication_date + + @publication_date.setter + def publication_date(self, publication_date): + if publication_date is not None: if isinstance(publication_date, string_types): publication_date = dateutil.parser.parse(publication_date) if not isinstance(publication_date, datetime): @@ -319,10 +297,11 @@ def publication_date(self, publication_date=None): if publication_date.tzinfo is None: raise ValueError('Datetime object has no timezone info') self.__publication_date = publication_date + else: + self.__publication_date = None - return self.__publication_date - - def media(self, media=None): + @property + def media(self): """Get or set the :class:`~feedgen.media.Media` object that is attached to this episode. @@ -333,13 +312,11 @@ def media(self, media=None): has listened to it). Therefore, you should only rely on this behaviour if you own the domain which the episodes reside on. If you don't, then you must set :py:meth:`.id` to an appropriate value manually. - - :param media: The Media object which points to the media file you want - to attach to this episode. - :type media: feedgen.media.Media or None - :returns: The media file attached to this episode. """ - if not media is None: + + @media.setter + def media(self, media): + if media is not None: # Test that the media quacks like a duck if hasattr(media, "url") and hasattr(media, "size") and \ hasattr(media, "type"): @@ -348,9 +325,11 @@ def media(self, media=None): else: raise TypeError("The parameter media must have the attributes " "url, size and type.") - return self.__media + else: + self.__media = None - def withhold_from_itunes(self, withhold_from_itunes=None): + @property + def withhold_from_itunes(self): """Get or set the iTunes block attribute. Use this to prevent episodes from appearing in the iTunes podcast directory. Note that the episode can still be found by inspecting the XML, so it is still public. @@ -362,47 +341,58 @@ def withhold_from_itunes(self, withhold_from_itunes=None): podcast on iTunes. This attribute defaults to ``False``, of course. - - :param withhold_from_itunes: Block podcast episode from iTunes. - :type withhold_from_itunes: bool - :returns: Whether the podcast episode is withheld from iTunes or not. """ - if not withhold_from_itunes is None: - self.__withhold_from_itunes = withhold_from_itunes return self.__withhold_from_itunes - def image(self, 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. + @withhold_from_itunes.setter + def withhold_from_itunes(self, withhold_from_itunes): + if withhold_from_itunes is not None: + if withhold_from_itunes is True or withhold_from_itunes is False: + self.__withhold_from_itunes = withhold_from_itunes + else: + raise TypeError("withhold_from_itunes expects bool or None, " + "got %s" % withhold_from_itunes) + else: + self.__withhold_from_itunes = None + + @property + def image(self): + """The podcast episode's image. + + .. warning:: + + Almost no podcatchers support this. iTunes supports it only if you + embed the cover in the media file (the same way you would embed + an album cover), and recommends you use Garageband's Enhanced + Podcast feature. If you don't, the podcast's image is used instead. + + This tag specifies the artwork for your podcast. 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. + 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. - - Oh, and iTunes doesn't support this. You need to embed the image inside - the media file as well (like regular album covers). The Podcast.image - attribute is used if not. + (CMYK is not supported). The URL must end in ".jpg" or ".png". - :param image: Image of the episode. - :type image: str - :returns: Image of the episode. + If you change an episode’s image, you should 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 iTunes to be able to automatically update + your cover art. """ - if not image is None: - lowercase_image = image.lower() + return self.__image + + @image.setter + def image(self, image): + if image is not None: + lowercase_image = str(image).lower() if not (lowercase_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not .%s' % image.split(".")[-1]) + raise ValueError('Image filename must end with png or jpg, not ' + '.%s' % image.split(".")[-1]) self.__image = image - return self.__image + else: + self.__image = None def itunes_duration(self, itunes_duration=None): """Get or set the duration of the podcast episode. The content of this @@ -420,6 +410,7 @@ def itunes_duration(self, itunes_duration=None): :returns: Duration of the podcast episode. """ if not itunes_duration is None: + # TODO: Make this a part of Media itunes_duration = str(itunes_duration) if len(itunes_duration.split(':')) > 3 or \ itunes_duration.lstrip('0123456789:') != '': @@ -427,77 +418,48 @@ def itunes_duration(self, itunes_duration=None): self.__itunes_duration = itunes_duration return self.itunes_duration - def explicit(self, 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. + @property + def explicit(self): + """Whether this podcast episode contains material which may be + inappropriate for children. - The value of the podcast's explicit attribute is used by default. + The value of the podcast's explicit attribute is used by default, if + this is ``None``. If you set this to ``True``, an "explicit" parental advisory graphic will appear in the Name column in iTunes. If the value is ``False``, the parental advisory type is considered Clean, meaning that no explicit language or adult content is included anywhere in this episode, and a "clean" graphic will appear. - - :param explicit: ``True`` if the podcast contains material - that may be inappropriate for children, ``False`` if it doesn't. - :type explicit: str - :returns: If the podcast episode contains explicit material. """ - if not explicit is None: - if not explicit in ('', 'yes', 'no', 'clean'): - raise ValueError('Invalid value "%s" for explicit tag' % explicit) - self.__explicit = explicit return self.__explicit - def is_closed_captioned(self, 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 - ``True`` and ``False``. - - :param is_closed_captioned: If the episode has closed captioning - support. - :type is_closed_captioned: bool or None - :returns: If the episode has closed captioning support. - """ - if not is_closed_captioned is None: - self.__is_closed_captioned = is_closed_captioned - return self.__is_closed_captioned - - def position(self, position=None): - """Get or set the itunes:order value of this 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 - multiple episodes share the same position, they will be sorted by their - publication date. - - To remove the order from the episode set the position to a value below - zero. - - :param position: This episode's desired position on the iTunes store - page. - :type position: int - :returns: This episode's desired position on the iTunes Store page. - """ - if not position is None: - self.__position = int(position) - return self.__position + @explicit.setter + def explicit(self, explicit): + if explicit is not None: + # Force explicit to be bool, so no one uses "no" and expects False + if explicit not in (True, False): + raise ValueError('Invalid value "%s" for explicit tag' + % explicit) + self.__explicit = explicit + else: + self.__explicit = None - def subtitle(self, 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. + @property + def position(self): + """A custom position for this episode on the iTunes store page. + + If you would like this episode to appear first, set it to ``1``. + If you want it second, set it to ``2``, and so on. If multiple episodes + share the same position, they will be sorted by their publication date. - :param subtitle: Subtitle of the podcast episode. - :type subtitle: str - :returns: Subtitle of the podcast episode. + To remove the order from the episode, set the position back to ``None``. """ - if not subtitle is None: - self.__subtitle = subtitle - return self.__subtitle + return self.__position + + @position.setter + def position(self, position): + if position is not None: + self.__position = int(position) + else: + self.__position = None diff --git a/feedgen/podcast.py b/feedgen/podcast.py index 9650a04..73c2866 100644 --- a/feedgen/podcast.py +++ b/feedgen/podcast.py @@ -391,8 +391,8 @@ def _create_rss(self): author.text = str(self.authors[0]) if self.publication_date is None: - episode_dates = [e.publication_date() for e in self.episodes - if e.publication_date() is not None] + episode_dates = [e.publication_date for e in self.episodes + if e.publication_date is not None] if episode_dates: actual_pubDate = max(episode_dates) else: diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 7e64195..b5ffa5a 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -9,7 +9,7 @@ import unittest from lxml import etree -from feedgen import Person, Media, Podcast +from feedgen import Person, Media, Podcast, htmlencode import datetime import pytz from dateutil.parser import parse as parsedate @@ -33,19 +33,19 @@ def setUp(self): fg.explicit = self.explicit fe = fg.add_episode() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The First Episode') + fe.id = 'http://lernfunk.de/media/654321/1' + fe.title = 'The First Episode' self.fe = fe #Use also the list directly fe = fg.Episode() fg.episodes.append(fe) - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Second Episode') + fe.id = 'http://lernfunk.de/media/654321/1' + fe.title = 'The Second Episode' fe = fg.add_episode() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') + fe.id = 'http://lernfunk.de/media/654321/1' + fe.title = 'The Third Episode' self.fg = fg @@ -65,8 +65,8 @@ def test_removeEntryByIndex(self): self.title = 'Some Testfeed' fe = fg.add_episode() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third BaseEpisode') + fe.id = 'http://lernfunk.de/media/654321/1' + fe.title = 'The Third BaseEpisode' assert len(fg.episodes) == 1 fg.episodes.pop(0) assert len(fg.episodes) == 0 @@ -77,8 +77,8 @@ def test_removeEntryByEntry(self): self.title = 'Some Testfeed' fe = fg.add_episode() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third BaseEpisode') + fe.id = 'http://lernfunk.de/media/654321/1' + fe.title = 'The Third BaseEpisode' assert len(fg.episodes) == 1 fg.episodes.remove(fe) @@ -87,8 +87,8 @@ def test_removeEntryByEntry(self): def test_idIsSet(self): guid = "http://example.com/podcast/episode1" episode = self.fg.Episode() - episode.title("My first episode") - episode.id(guid) + episode.title = "My first episode" + episode.id = guid item = episode.rss_entry() assert item.find("guid").text == guid @@ -96,46 +96,42 @@ def test_idIsSet(self): def test_idNotSetButEnclosureIsUsed(self): guid = "http://example.com/podcast/episode1.mp3" episode = self.fg.Episode() - episode.title("My first episode") - episode.media(Media(guid, 97423487, "audio/mpeg")) + episode.title = "My first episode" + episode.media = Media(guid, 97423487, "audio/mpeg") item = episode.rss_entry() assert item.find("guid").text == guid def test_idSetToFalseSoEnclosureNotUsed(self): episode = self.fg.Episode() - episode.title("My first episode") - episode.media(Media("http://example.com/podcast/episode1.mp3", - 34328731, "audio/mpeg")) - episode.id(False) + episode.title = "My first episode" + episode.media = Media("http://example.com/podcast/episode1.mp3", + 34328731, "audio/mpeg") + episode.id = False item = episode.rss_entry() assert item.find("guid") is None def test_feedPubDateUsesNewestEpisode(self): - self.fg.episodes[0].publication_date( + self.fg.episodes[0].publication_date = \ datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) - ) - self.fg.episodes[1].publication_date( + self.fg.episodes[1].publication_date = \ datetime.datetime(2016, 1, 3, 12, 22, tzinfo=pytz.utc) - ) - self.fg.episodes[2].publication_date( + self.fg.episodes[2].publication_date = \ datetime.datetime(2014, 3, 2, 13, 11, tzinfo=pytz.utc) - ) rss = self.fg._create_rss() pubDate = rss.find("channel").find("pubDate") assert pubDate is not None parsedPubDate = parsedate(pubDate.text) - assert parsedPubDate == self.fg.episodes[1].publication_date() + assert parsedPubDate == self.fg.episodes[1].publication_date def test_feedPubDateNotOverriddenByEpisode(self): - self.fg.episodes[0].publication_date( + self.fg.episodes[0].publication_date = \ datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) - ) pubDate = self.fg._create_rss().find("channel").find("pubDate") # Now it uses the episode's published date assert pubDate is not None - assert parsedate(pubDate.text) == self.fg.episodes[0].publication_date() + assert parsedate(pubDate.text) == self.fg.episodes[0].publication_date new_date = datetime.datetime(2016, 1, 2, 3, 4, tzinfo=pytz.utc) self.fg.publication_date = new_date @@ -145,9 +141,8 @@ def test_feedPubDateNotOverriddenByEpisode(self): assert parsedate(pubDate.text) == new_date def test_feedPubDateDisabled(self): - self.fg.episodes[0].publication_date( + self.fg.episodes[0].publication_date = \ datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) - ) self.fg.publication_date = False pubDate = self.fg._create_rss().find("channel").find("pubDate") assert pubDate is None # Not found! @@ -227,7 +222,7 @@ def do_authorsInvalidAssignment(self): def test_media(self): media = Media("http://example.org/episodes/1.mp3", 14536453, "audio/mpeg") - self.fe.media(media) + self.fe.media = media enclosure = self.fe.rss_entry().find("enclosure") self.assertEqual(enclosure.get("url"), media.url) @@ -235,20 +230,20 @@ def test_media(self): self.assertEqual(enclosure.get("type"), media.type) # Ensure duck-typing is checked at assignment time - self.assertRaises(TypeError, self.fe.media, media.url) - self.assertRaises(TypeError, self.fe.media, + self.assertRaises(TypeError, setattr, self.fe, "media", media.url) + self.assertRaises(TypeError, setattr, self.fe, "media", (media.url, media.size, media.type)) def test_withholdFromItunesOffByDefault(self): - assert not self.fe.withhold_from_itunes() + assert not self.fe.withhold_from_itunes def test_withholdFromItunes(self): - self.fe.withhold_from_itunes(True) + self.fe.withhold_from_itunes = True itunes_block = self.fe.rss_entry().find("{%s}block" % self.itunes_ns) assert itunes_block is not None self.assertEqual(itunes_block.text.lower(), "yes") - self.fe.withhold_from_itunes(False) + self.fe.withhold_from_itunes = False itunes_block = self.fe.rss_entry().find("{%s}block" % self.itunes_ns) assert itunes_block is None @@ -262,15 +257,15 @@ def test_summaries(self): assert ce is None # Test that description is filled when one of the summaries is set - self.fe.summary("A short summary") + self.fe.summary = "A short summary" d = self.fe.rss_entry().find("description") assert d is not None assert "A short summary" in d.text ce = self.fe.rss_entry().find(content_encoded) assert ce is None - self.fe.summary(False) - self.fe.long_summary("A long summary with more words") + self.fe.summary = False + self.fe.long_summary = "A long summary with more words" d = self.fe.rss_entry().find("description") assert d is not None assert "A long summary with more words" in d.text @@ -278,8 +273,8 @@ def test_summaries(self): assert ce is None # Test that description and content:encoded are used when both are set - self.fe.summary("A short summary") - self.fe.long_summary("A long summary with more words") + self.fe.summary = "A short summary" + self.fe.long_summary = "A long summary with more words" d = self.fe.rss_entry().find("description") assert d is not None assert "A short summary" in d.text @@ -288,12 +283,12 @@ def test_summaries(self): assert "A long summary with more words" in ce.text def test_summariesHtml(self): - self.fe.summary("A cool summary") + self.fe.summary = "A cool summary" d = self.fe.rss_entry().find("description") assert d is not None assert "A cool summary" in d.text - self.fe.summary("A cool summary", html=False) + self.fe.summary = htmlencode("A cool summary") d = self.fe.rss_entry().find("description") assert d is not None assert "A <b>cool</b> summary" in d.text From 3a88bf31a19853d64ac3f6b4fe4688366f4b77ef Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 00:23:14 +0200 Subject: [PATCH 081/200] Rename BaseEpisode to Episode, many small improvements to Episode --- doc/api.episode.rst | 8 +- doc/api.rst | 2 +- doc/user/basic_usage_guide/part_2.rst | 51 +++++++------ feedgen/__init__.py | 2 +- feedgen/episode.py | 63 ++++++++++------ feedgen/podcast.py | 103 +++++++++++--------------- feedgen/tests/test_entry.py | 10 +-- 7 files changed, 121 insertions(+), 118 deletions(-) diff --git a/doc/api.episode.rst b/doc/api.episode.rst index 7c0d8fb..aad4da8 100644 --- a/doc/api.episode.rst +++ b/doc/api.episode.rst @@ -1,6 +1,6 @@ -=================== -feedgen.BaseEpisode -=================== +=============== +feedgen.Episode +=============== -.. autoclass:: feedgen.BaseEpisode +.. autoclass:: feedgen.Episode :members: diff --git a/doc/api.rst b/doc/api.rst index a686229..4080b51 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -27,7 +27,7 @@ API Documentation .. autosummary:: feedgen.Podcast - feedgen.BaseEpisode + feedgen.Episode feedgen.Person feedgen.Media feedgen.Category diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index 391a2a4..6eb3881 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -42,19 +42,20 @@ well as a short subtitle:: Read more: -* :attr:`~feedgen.BaseEpisode.title` -* :attr:`~feedgen.BaseEpisode.subtitle` -* :attr:`~feedgen.BaseEpisode.summary` -* :attr:`~feedgen.BaseEpisode.long_summary` +* :attr:`~feedgen.Episode.title` +* :attr:`~feedgen.Episode.subtitle` +* :attr:`~feedgen.Episode.summary` +* :attr:`~feedgen.Episode.long_summary` Enclosing media ^^^^^^^^^^^^^^^ -Of course, this isn't much of a podcast if we don't have any **media** -attached to it! :: +Of course, this isn't much of a podcast if we don't have any +:attr:`~feedgen.Episode.media` attached to it! :: from datetime import timedelta + from feedgen import Media my_episode.media = Media("http://example.com/podcast/s01e10.mp3", size=17475653, type="audio/mpeg", # Optional, can be determined @@ -64,8 +65,9 @@ attached to it! :: Normally, you must specify how big the **file size** is in bytes (and the MIME type, if the file extension is unknown to iTunes), but PodcastGenerator -can send a HEAD request to the URL and retrieve the missing information. This is -done by calling :meth:`Media.create_from_server_response ` +can send a HEAD request to the URL and retrieve the missing information +(file size and type). This is done by calling +:meth:`Media.create_from_server_response ` instead of using the constructor directly. You must pass in the `requests `_ module, so it must be installed! :: @@ -78,20 +80,23 @@ module, so it must be installed! :: ) -The **type** of the media file is derived from the URI ending. Even though you +The **type** of the media file is derived from the URI ending, if you don't +provide it yourself. Even though you technically can have file names which don't end in their actual file extension, iTunes will use the file extension to determine what type of file it is, without even asking the server. You must therefore make sure your media files have the correct file extension. If you don't care about compatibility with iTunes, you can provide the MIME type yourself. +:meth:`Media.create_from_server_response ` +will also fetch the type for you, if it's not specified. The **duration** is also important to include, for your listeners' convenience. Without it, they won't know how long an episode is before they start downloading -and listening. +and listening. The duration cannot be fetched from the server automatically. Read more about: -* :attr:`feedgen.BaseEpisode.media` (the attribute) +* :attr:`feedgen.Episode.media` (the attribute) * :class:`feedgen.Media` (the class which you use as value) @@ -109,7 +114,7 @@ That is, given the example above, the id of ``my_episode`` would be An episode's ID should never change. Therefore, **if you don't set id, the media URL must never change either**. -Read more about :attr:`the id attribute `. +Read more about :attr:`the id attribute `. Episode's publication date ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -132,7 +137,7 @@ will get a new episode which appears to have existed for longer than it has. my_episode.publication_date = datetime.datetime(2016, 5, 18, 10, 0, tzinfo=pytz.utc) -Read more about :attr:`the publication_date attribute `. +Read more about :attr:`the publication_date attribute `. The Link @@ -141,7 +146,7 @@ The Link If you're publishing articles along with your podcast episodes, you should link to the relevant article. Examples can be linking to the sound on SoundCloud or the post on your website. Usually, your -listeners expect to find the entirety of the :attr:`~feedgen.BaseEpisode.summary` by following +listeners expect to find the entirety of the :attr:`~feedgen.Episode.summary` by following the link. :: my_episode.link = "http://example.com/article/2016/05/18/Best-example" @@ -151,7 +156,7 @@ the link. :: If you don't have anything to link to, then that's fine as well. No link is better than a disappointing link. -Read more about :attr:`the link attribute `. +Read more about :attr:`the link attribute `. The Authors @@ -159,8 +164,8 @@ The Authors .. note:: - Some of those attributes correspond to attributes found in - :class:`~feedgen.Podcast`. In such cases, you should only set those + Some of the following attributes (not just authors) correspond to attributes + found in :class:`~feedgen.Podcast`. In such cases, you should only set those attributes at the episode level if they **differ** from their value at the podcast level. @@ -178,7 +183,7 @@ You can even have multiple authors:: my_episode.authors = [Person("Joe Bob"), Person("Alice Bob")] -Read more about :attr:`an episode's authors `. +Read more about :attr:`an episode's authors `. Less used attributes @@ -195,10 +200,10 @@ Less used attributes More details: -* :attr:`~feedgen.BaseEpisode.image` -* :attr:`~feedgen.BaseEpisode.explicit` -* :attr:`~feedgen.BaseEpisode.is_closed_captioned` -* :attr:`~feedgen.BaseEpisode.position` -* :attr:`~feedgen.BaseEpisode.withhold_from_itunes` +* :attr:`~feedgen.Episode.image` +* :attr:`~feedgen.Episode.explicit` +* :attr:`~feedgen.Episode.is_closed_captioned` +* :attr:`~feedgen.Episode.position` +* :attr:`~feedgen.Episode.withhold_from_itunes` The final step is :doc:`part_3` diff --git a/feedgen/__init__.py b/feedgen/__init__.py index c3a8325..8b0bfe0 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from .podcast import Podcast -from .episode import BaseEpisode +from .episode import Episode from .media import Media from .person import Person from .not_supported_by_itunes_warning import NotSupportedByItunesWarning diff --git a/feedgen/episode.py b/feedgen/episode.py index 250c8f6..852b3c5 100644 --- a/feedgen/episode.py +++ b/feedgen/episode.py @@ -7,24 +7,37 @@ :license: FreeBSD and LGPL, see license.* for more details. """ -import collections - from lxml import etree from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.util import ensure_format, formatRFC2822, htmlencode, \ - listToHumanreadableStr +from feedgen.util import formatRFC2822, listToHumanreadableStr from feedgen.compat import string_types from builtins import str -class BaseEpisode(object): +class Episode(object): """Class representing an episode in a podcast. Corresponds to an RSS Item. - Its name indicates that this is the superclass for all episode classes. - It is not meant to indicate that this class misses functionality; in 99% - of all cases, this class is the right one to use for episodes. + You must provide either :attr:`.title` or :attr:`.summary`. + + Episodes are mostly independent from Podcast, except for :attr:`.position` + and the fact that many values default to their corresponding value in the + :class:`~feedgen.Podcast` the episode's a part of. To add an episode to a + podcast:: + + >>> import feedgen + >>> p = feedgen.Podcast() + >>> episode = feedgen.Episode() + >>> p.episodes.append(episode) + + You may also replace the last two lines a shortcut: + + >>> episode = p.add_episode(Episode()) + + + See the :doc:`Basic Usage Guide
` for a + friendlier introduction to episodes. """ def __init__(self): @@ -45,12 +58,12 @@ def __init__(self): the "circled i" in the Description column is clicked. This field can be up to 4000 characters in length. - See also :py:attr:`.BaseEpisode.subtitle` and - :py:attr:`.BaseEpisode.long_summary`.""" + See also :py:attr:`.Episode.subtitle` and + :py:attr:`.Episode.long_summary`.""" self.long_summary = None """A long (read: full) summary, which supplements the shorter - :attr:`~feedgen.item.BaseEpisode.summary`. + :attr:`~feedgen.Episode.summary`. This attribute should be seen as a full, longer variation of summary if summary exists. Even then, the long_summary should be @@ -86,7 +99,7 @@ def __init__(self): This property corresponds to the RSS GUID element.""" self.link = None - """Get or set the link to the full version of this episode description. + """The link to the full version of this episode description. Remember to start the link with the scheme, e.g. https://.""" self.__publication_date = None @@ -106,17 +119,19 @@ def __init__(self): self.__explicit = None self.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 - ``True`` and ``False``.""" + """Whether this podcast includes a video episode with embedded closed + captioning support. + + The two values for this tag are ``True`` and + ``False``.""" self.__position = None self.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.""" + """A short subtitle. + + This is shown in the Description column in iTunes. + The subtitle displays best if it is only a few words long.""" def rss_entry(self): """Create a RSS item and return it.""" @@ -233,7 +248,7 @@ def rss_entry(self): @property def authors(self): - """List of :class:`~feedgen.person.Person` that contributed to this + """List of :class:`~feedgen.Person` that contributed to this episode. The authors don't need to have both name and email set. The names are @@ -246,7 +261,7 @@ def authors(self): Any value you assign to authors will be automatically converted to a list, but only if it's iterable (like tuple, set and so on). It is an - error to assign a single :class:`~feedgen.person.Person` object to this + error to assign a single :class:`~feedgen.Person` object to this attribute:: >>> # This results in an error @@ -302,16 +317,16 @@ def publication_date(self, publication_date): @property def media(self): - """Get or set the :class:`~feedgen.media.Media` object that is attached + """Get or set the :class:`~feedgen.Media` object that is attached to this episode. - Note that if :py:meth:`.id` is not set, the enclosure's url is used as + Note that if :py:attr:`.id` is not set, the enclosure's url is used as the globally unique identifier. If you rely on this, you should make sure the url never changes, since changing the id messes up with clients (they will think this episode is new again, even if the user already has listened to it). Therefore, you should only rely on this behaviour if you own the domain which the episodes reside on. If you don't, then - you must set :py:meth:`.id` to an appropriate value manually. + you must set :py:attr:`.id` to an appropriate value manually. """ @media.setter diff --git a/feedgen/podcast.py b/feedgen/podcast.py index 73c2866..00692d0 100644 --- a/feedgen/podcast.py +++ b/feedgen/podcast.py @@ -13,7 +13,7 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.episode import BaseEpisode +from feedgen.episode import Episode from feedgen.util import ensure_format, formatRFC2822, listToHumanreadableStr from feedgen.person import Person import feedgen.version @@ -41,7 +41,7 @@ class Podcast(object): def __init__(self): self.__episodes = [] """The list used by self.episodes.""" - self.__episode_class = BaseEpisode + self.__episode_class = Episode """The internal value used by self.Episode.""" ## RSS @@ -195,79 +195,64 @@ def __init__(self): def episodes(self): """List of episodes that are part of this podcast. - This property is read-only, in the sense that you cannot assign a new list to it. - You are, however, able to add, get and remove individual episodes from the (existing) list. - - See :py:meth:`.add_episode` for an easy way to create new episodes and assign them to this podcast - in one call. + See :py:meth:`.add_episode` for an easy way to create new episodes and + assign them to this podcast in one call. """ return self.__episodes + @episodes.setter + def episodes(self, episodes): + # Ensure it is a list + self.__episodes = list(episodes) if not isinstance(episodes, list) \ + else episodes + @property - def Episode(self): + def episode_class(self): """Class used to represent episodes. - This is actually a property (variable) which points to the correct - class. It is used by :py:meth:`.add_episode` when creating new episode - objects, and you should use it too when adding episodes. - - By default, this property points to :py:class:`BaseEpisode`. + This is used by :py:meth:`.add_episode` when creating new episode + objects, and you may use it too when creating episodes. - When assigning a new class to Episode, you must make sure the new value - (1) is a class and not an instance, and (2) is a subclass of BaseEpisode - (or is BaseEpisode itself). + By default, this property points to :py:class:`Episode`. - This property exists so you can change which class episodes should have, without needing to change the code - that creates those episodes. Thus, changing this property changes what class is used by self.add_episode(). - An example would be if you created a subclass of Podcast together with a - subclass of Episode, and wanted users of your new Podcast subclass to be using your new Episode subclass - automatically. All you need to do, is to change the initial value of Episode in your Podcast subclass. - Another example is if you want to use another class for episodes, while - still enjoying the benefits of using :py:meth:`.add_episode`. - You as a users, on the other hand, won't have to change your code when changing between different - subclasses of Podcast that expect different subclasses of Episode. - - It is still possible for you to hardcode what Episode subclass you want to use, either by calling its - constructor without using this property, or by overriding its value. + When assigning a new class to ``episode_class``, you must make sure the + new value (1) is a class and not an instance, and (2) is a subclass of + Episode (or is Episode itself). Example of use:: >>> # Create new podcast - >>> from feedgen import Podcast + >>> from feedgen import Podcast, Episode >>> p = Podcast() - - >>> # Here's how you would create a new episode object, the OK way - >>> episode1 = p.Episode() + >>> # Normal way of creating new episodes + >>> episode1 = Episode() >>> p.episodes.append(episode1) - >>> episode1.title("My awesome episode") - - >>> # Best way to create new episode object (it is added to the podcast automatically) + >>> # Or use add_episode (and thus episode_class indirectly) >>> episode2 = p.add_episode() - >>> episode2.title("My even more awesome episode") - - >>> # If you want to use another class for episodes, do it like this + >>> # Or use episode_class directly + >>> episode3 = p.episode_class() + >>> p.episodes.append(episode3) + >>> # Say you want to use AlternateEpisode class instead of Episode >>> from mymodule import AlternateEpisode - >>> p.Episode = AlternateEpisode - >>> episode3 = p.add_episode() # It is also okay to use p.episodes - >>> episode3.title("This is an instance of AlternateEpisode!") - - >>> # !!! DON'T DO THE FOLLOWING, unless you want to hard code what class is used !!! - >>> episode3 = AlternateEpisode() - >>> p.episodes.append(episode3) # or p.add_episode(episode3) - >>> episode3.title("My awful episode :(") + >>> p.episode_class = AlternateEpisode + >>> episode4 = p.add_episode() + >>> episode4.title("This is an instance of AlternateEpisode!") """ return self.__episode_class - @Episode.setter - def Episode(self, value): + @episode_class.setter + def episode_class(self, value): if not inspect.isclass(value): - raise ValueError("New Episode must NOT be an _instance_ of the desired class, but rather the class " - "itself. You can generally achieve this by removing the parenthesis from the " - "constructor call. For example, use Episode, not Episode().") - elif issubclass(value, BaseEpisode): + raise ValueError("New episode_class must NOT be an _instance_ of " + "the desired class, but rather the class itself. " + "You can generally achieve this by removing the " + "parenthesis from the constructor call. For " + "example, use Episode, not Episode().") + elif issubclass(value, Episode): self.__episode_class = value else: - raise ValueError("New Episode must be Episode or a descendant of it (so the API still works).") + raise ValueError("New episode_class must be Episode or a descendant" + " of it (so the API still works).") def add_episode(self, new_episode=None): """Shorthand method which adds a new episode to the feed, creating an @@ -289,16 +274,14 @@ def add_episode(self, new_episode=None): >>> another_entry.title('My second feed entry') 'My second feed entry' - For the curious, this is a shorthand method which basically reads like:: - - if new_episode is None: - new_episode = self.Episode() - self.episodes.append(new_episode) - return new_episode + Internally, this method creates a new instance of + :attr:`~feedgen.Episode.episode_class`, which means you can change what + type of objects are created by changing + :attr:`~feedgen.Episode.episode_class`. """ if new_episode is None: - new_episode = self.Episode() + new_episode = self.episode_class() self.episodes.append(new_episode) return new_episode diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index b5ffa5a..4e776da 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -9,7 +9,7 @@ import unittest from lxml import etree -from feedgen import Person, Media, Podcast, htmlencode +from feedgen import Person, Media, Podcast, htmlencode, Episode import datetime import pytz from dateutil.parser import parse as parsedate @@ -38,7 +38,7 @@ def setUp(self): self.fe = fe #Use also the list directly - fe = fg.Episode() + fe = Episode() fg.episodes.append(fe) fe.id = 'http://lernfunk.de/media/654321/1' fe.title = 'The Second Episode' @@ -86,7 +86,7 @@ def test_removeEntryByEntry(self): def test_idIsSet(self): guid = "http://example.com/podcast/episode1" - episode = self.fg.Episode() + episode = Episode() episode.title = "My first episode" episode.id = guid item = episode.rss_entry() @@ -95,7 +95,7 @@ def test_idIsSet(self): def test_idNotSetButEnclosureIsUsed(self): guid = "http://example.com/podcast/episode1.mp3" - episode = self.fg.Episode() + episode = Episode() episode.title = "My first episode" episode.media = Media(guid, 97423487, "audio/mpeg") @@ -103,7 +103,7 @@ def test_idNotSetButEnclosureIsUsed(self): assert item.find("guid").text == guid def test_idSetToFalseSoEnclosureNotUsed(self): - episode = self.fg.Episode() + episode = Episode() episode.title = "My first episode" episode.media = Media("http://example.com/podcast/episode1.mp3", 34328731, "audio/mpeg") From 286cc69fd4868a67741c90ad3e4e265fbb7be52b Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 01:44:46 +0200 Subject: [PATCH 082/200] Move itunes_duration to Media, call it duration --- doc/user/basic_usage_guide/part_2.rst | 3 +- feedgen/__main__.py | 4 +- feedgen/episode.py | 32 ++----------- feedgen/media.py | 69 +++++++++++++++++++++++---- feedgen/tests/test_media.py | 47 +++++++++++++++++- 5 files changed, 116 insertions(+), 39 deletions(-) diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index 6eb3881..968a18c 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -92,7 +92,8 @@ will also fetch the type for you, if it's not specified. The **duration** is also important to include, for your listeners' convenience. Without it, they won't know how long an episode is before they start downloading -and listening. The duration cannot be fetched from the server automatically. +and listening. The duration cannot be fetched from the server automatically, and +must be an instance of :class:`datetime.timedelta`. Read more about: diff --git a/feedgen/__main__.py b/feedgen/__main__.py index acf6f5e..861fc28 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -70,7 +70,9 @@ def main(): e1.link = 'http://example.com' e1.authors = [Person('Lars Kiesow', 'lkiesow@uos.de')] e1.publication_date = datetime.datetime(2014, 5, 17, 13, 37, 10, tzinfo=pytz.utc) - e1.media = Media("http://example.com/episodes/loremipsum.mp3", 454599964) + e1.media = Media("http://example.com/episodes/loremipsum.mp3", 454599964, + duration= + datetime.timedelta(hours=1, minutes=32, seconds=19)) # Should we just print out, or write to file? if arg == 'rss': diff --git a/feedgen/episode.py b/feedgen/episode.py index 852b3c5..a9ceaf4 100644 --- a/feedgen/episode.py +++ b/feedgen/episode.py @@ -209,6 +209,10 @@ def rss_entry(self): enclosure.attrib['length'] = str(self.__media.size) enclosure.attrib['type'] = self.__media.type + if self.__media.duration: + duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) + duration.text = self.__media.duration_str + if self.__publication_date: pubDate = etree.SubElement(entry, 'pubDate') pubDate.text = formatRFC2822(self.__publication_date) @@ -222,10 +226,6 @@ def rss_entry(self): image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) image.attrib['href'] = self.__image - if self.__itunes_duration: - duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) - duration.text = self.__itunes_duration - if self.__explicit is not None: explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) explicit.text = "Yes" if self.__explicit else "No" @@ -409,30 +409,6 @@ def image(self, image): else: self.__image = None - 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. - :type itunes_duration: str or int - :returns: Duration of the podcast episode. - """ - if not itunes_duration is None: - # TODO: Make this a part of Media - itunes_duration = str(itunes_duration) - if len(itunes_duration.split(':')) > 3 or \ - itunes_duration.lstrip('0123456789:') != '': - ValueError('Invalid duration format "%s"' % itunes_duration) - self.__itunes_duration = itunes_duration - return self.itunes_duration - @property def explicit(self): """Whether this podcast episode contains material which may be diff --git a/feedgen/media.py b/feedgen/media.py index 907de98..1d84362 100644 --- a/feedgen/media.py +++ b/feedgen/media.py @@ -1,5 +1,6 @@ import warnings from future.moves.urllib.parse import urlparse +import datetime from feedgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning from feedgen import version @@ -44,7 +45,8 @@ class Media(object): * EPUB All attributes will always have a value, except size which can be 0 if the - size cannot be determined by any means (eg. if it's a stream). + size cannot be determined by any means (eg. if it's a stream) and duration + which is optional (but recommended). """ @@ -58,14 +60,16 @@ class Media(object): 'epub': 'document/x-epub', } - def __init__(self, url, size=0, type=None): + def __init__(self, url, size=0, type=None, duration=None): self._url = None self._size = None self._type = None + self._duration = None self.url = url self.size = size self.type = type or self.get_type(url) + self.duration = duration @property def url(self): @@ -217,8 +221,55 @@ def get_type(self, url): "clients can see what type of file it is." % file_extension) from e + @property + def duration(self): + """The duration of the media file. + + :type: :class:`datetime.timedelta` + :raises: :obj:`TypeError` if you try to assign anything other than + :class:`datetime.timedelta` instances or None to this attribute. + Raises :obj:`ValueError` if a negative timedelta value is + given. + """ + return self._duration + + @duration.setter + def duration(self, duration): + if duration is None: + self._duration = None + elif not isinstance(duration, datetime.timedelta): + raise TypeError("duration must be a datetime.timedelta instance!") + elif duration.total_seconds() < 0: + raise ValueError("expected a positive timedelta, got %s" % duration) + else: + self._duration = duration + + @property + def duration_str(self): + """:attr:`.duration`, formatted as a string according to iTunes' specs. + That is, HH:MM:SS if it lasts more than an hour, or MM:SS if it lasts + less than an hour. + + This is just an alternate, read-only view of :attr:`.duration`. + + If :attr:`.duration` is :obj:`None`, this will be :obj:`None` as well. + """ + if self.duration is None: + return None + else: + hours = self.duration.days * 24 + \ + self.duration.seconds // 3600 + minutes = (self.duration.seconds // 60) % 60 + seconds = self.duration.seconds % 60 + + if hours: + return "%02d:%02d:%02d" % (hours, minutes, seconds) + else: + return "%02d:%02d" % (minutes, seconds) + @classmethod - def create_from_server_response(cls, requests, url, size=None, type=None): + def create_from_server_response(cls, requests, url, size=None, type=None, + duration=None): """Create new Media object, with size and/or type fetched from the server when not given. @@ -234,7 +285,8 @@ def create_from_server_response(cls, requests, url, size=None, type=None): >>> m = Media.create_from_server_response(requests, ... "http://example.com/episodes/ep1.mp3") >>> m - Media(url=http://example.com/episodes/ep1.mp3, size=252345991, type=audio/mpeg) + Media(url=http://example.com/episodes/ep1.mp3, size=252345991, + type=audio/mpeg, duration=None) :param requests: Either the @@ -248,11 +300,12 @@ def create_from_server_response(cls, requests, url, size=None, type=None): :param type: The media type of the file. Will be fetched from server if not given. :type type: str or None + :param duration: The media's duration. + :type duration: :class:`datetime.timedelta` or :obj:`None`. :returns: New instance of Media with all fields filled in. :raises: The appropriate requests exceptions are thrown when networking errors occur. RuntimeError is thrown if some information isn't given and isn't found in the server's response.""" - # TODO: Create unit tests for this factory (it is not covered yet!) if not (size and type): r = requests.head(url, allow_redirects=True, timeout=5.0, headers={"User-Agent": version.name + " v" + @@ -272,11 +325,11 @@ def create_from_server_response(cls, requests, url, size=None, type=None): "server when sending HEAD request to %s" % url) - return Media(url, size, type) + return Media(url, size, type, duration) def __str__(self): - return "Media(url=%s, size=%s, type=%s)" % \ - (self.url, self.size, self.type) + return "Media(url=%s, size=%s, type=%s, duration=%s)" % \ + (self.url, self.size, self.type, self.duration) def __repr__(self): return self.__str__() diff --git a/feedgen/tests/test_media.py b/feedgen/tests/test_media.py index 20bb980..06fe1bc 100644 --- a/feedgen/tests/test_media.py +++ b/feedgen/tests/test_media.py @@ -1,6 +1,7 @@ from future.utils import iteritems import unittest import warnings +from datetime import timedelta from feedgen import Media, NotSupportedByItunesWarning @@ -11,6 +12,7 @@ def setUp(self): self.size = 144253424 self.type = "audio/mpeg" self.expected_type = ("audio/mpeg3", "audio/x-mpeg-3", "audio/mpeg") + self.duration = timedelta(hours=1, minutes=32, seconds=44) warnings.simplefilter("ignore") def test_constructorOneArgument(self): @@ -31,6 +33,10 @@ def test_constructorThreeArguments(self): assert m.size == self.size assert m.type == self.type + def test_constructorDuration(self): + m = Media(self.url, self.size, self.type, self.duration) + assert m.duration ==self.duration + def test_assigningUrl(self): m = Media(self.url) another_url = "http://example.com/2016/5/17/The+awful+episode.mp3" @@ -156,7 +162,44 @@ def test_strToSize(self): for (str_size, expected_size) in iteritems(sizes): self.assertEqual(expected_size, Media._str_to_bytes(str_size)) + def test_assigningDuration(self): + m = Media(self.url, self.size, self.type, self.duration) + another_duration = timedelta(hours=0, minutes=32, seconds=23) + m.duration = another_duration + self.assertEqual(m.duration, another_duration) + + def test_assigningNegativeDuration(self): + self.assertRaises(ValueError, Media, self.url, self.size, self.type, + timedelta(hours=-1, minutes=3)) + + def test_assigningNotDuration(self): + self.assertRaises(TypeError, Media, self.url, self.size, self.type, + "01:32:13") + + def test_durationToStr(self): + m = Media(self.url, self.size, self.type, timedelta(hours=1)) + self.assertEqual(m.duration_str, "01:00:00") + + m.duration = timedelta(days=1) + self.assertEqual(m.duration_str, "24:00:00") + + m.duration = timedelta(minutes=1) + self.assertEqual(m.duration_str, "01:00") + + m.duration = timedelta(seconds=1) + self.assertEqual(m.duration_str, "00:01") + + m.duration = timedelta(days=1, hours=2) + self.assertEqual(m.duration_str, "26:00:00") + + m.duration = timedelta(hours=1, minutes=32, seconds=13) + self.assertEqual(m.duration_str, "01:32:13") + + m.duration = timedelta(hours=1, minutes=9, seconds=3) + self.assertEqual(m.duration_str, "01:09:03") + def test_createFromServerResponse(self): + # Mock our own requests object url = self.url type = self.type size = self.size @@ -182,9 +225,11 @@ def raise_for_status(): return MyLittleResponse - m = Media.create_from_server_response(MyLittleRequests, url) + m = Media.create_from_server_response(MyLittleRequests, url, + duration=self.duration) self.assertEqual(m.url, url) self.assertEqual(m.size, size) self.assertEqual(m.type, type) + self.assertEqual(m.duration, self.duration) From 9dc15b86dbcb7bb9126907a8555827c523ba8c80 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 11:46:58 +0200 Subject: [PATCH 083/200] Rename test modules so they match the class names --- Makefile | 2 +- feedgen/tests/{test_entry.py => test_episode.py} | 0 feedgen/tests/{test_feed.py => test_podcast.py} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename feedgen/tests/{test_entry.py => test_episode.py} (100%) rename feedgen/tests/{test_feed.py => test_podcast.py} (100%) diff --git a/Makefile b/Makefile index c563d31..1617e53 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ publish: sdist python setup.py register sdist upload test: - @python -m unittest feedgen.tests.test_feed feedgen.tests.test_entry \ + @python -m unittest feedgen.tests.test_podcast feedgen.tests.test_episode \ feedgen.tests.test_person feedgen.tests.test_media \ feedgen.tests.test_util feedgen.tests.test_category python -m feedgen rss > /dev/null diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_episode.py similarity index 100% rename from feedgen/tests/test_entry.py rename to feedgen/tests/test_episode.py diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_podcast.py similarity index 100% rename from feedgen/tests/test_feed.py rename to feedgen/tests/test_podcast.py From 89b56bb67fc07a8f0771c00a5a69222f4f64137f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 12:48:28 +0200 Subject: [PATCH 084/200] Make it possible to populate attributes in constructor --- doc/user/basic_usage_guide/part_2.rst | 15 +++++++ feedgen/episode.py | 49 ++++++++++++++++++----- feedgen/tests/test_episode.py | 57 +++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index 968a18c..1fe2a65 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -207,4 +207,19 @@ More details: * :attr:`~feedgen.Episode.position` * :attr:`~feedgen.Episode.withhold_from_itunes` + +Shortcut for filling in data +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Instead of assigning those values one at a time, you can assign them all in +one go in the constructor. Just use the attribute name as the keyword:: + + Episode( + =, + =, + ... + ) + +See also the example in :doc:`the API Documentation `. + The final step is :doc:`part_3` diff --git a/feedgen/episode.py b/feedgen/episode.py index a9ceaf4..7eca443 100644 --- a/feedgen/episode.py +++ b/feedgen/episode.py @@ -14,33 +14,55 @@ from feedgen.util import formatRFC2822, listToHumanreadableStr from feedgen.compat import string_types from builtins import str +from future.utils import iteritems class Episode(object): """Class representing an episode in a podcast. Corresponds to an RSS Item. - You must provide either :attr:`.title` or :attr:`.summary`. + When creating a new Episode, you can populate any attribute + using keyword arguments. Use the attribute's name on the left side of + the equals sign and its value on the right side. Here's an example:: - Episodes are mostly independent from Podcast, except for :attr:`.position` - and the fact that many values default to their corresponding value in the - :class:`~feedgen.Podcast` the episode's a part of. To add an episode to a - podcast:: + >>> # This... + >>> ep = Episode() + >>> ep.title = "Exploring the RTS genre" + >>> ep.summary = "Tory and I talk about a genre of games we've " + \\ + ... "never dared try out before now..." + >>> # ...is equal to this: + >>> ep = Episode( + ... title="Exploring the RTS genre", + ... summary="Tory and I talk about a genre of games we've " + ... "never dared try out before now..." + ... ) + + :raises: TypeError if you try to set an attribute which doesn't exist, + ValueError if you set an attribute to an invalid value. + + You must have filled in either :attr:`.title` or :attr:`.summary` before + the RSS is generated. + + To add an episode to a podcast:: >>> import feedgen >>> p = feedgen.Podcast() >>> episode = feedgen.Episode() >>> p.episodes.append(episode) - You may also replace the last two lines a shortcut: + You may also replace the last two lines with a shortcut:: >>> episode = p.add_episode(Episode()) - See the :doc:`Basic Usage Guide
` for a - friendlier introduction to episodes. + .. seealso:: + + The :doc:`Basic Usage Guide ` + A friendlier introduction to episodes. """ - def __init__(self): + def __init__(self, **kwargs): + """ + """ # RSS self.__authors = [] self.summary = None @@ -133,6 +155,14 @@ def __init__(self): This is shown in the Description column in iTunes. The subtitle displays best if it is only a few words long.""" + # It is time to assign the keyword arguments + for attribute, value in iteritems(kwargs): + if hasattr(self, attribute): + setattr(self, attribute, value) + else: + raise TypeError("Keyword argument %s (with value %s) not " + "recognized!" % (attribute, value)) + def rss_entry(self): """Create a RSS item and return it.""" @@ -328,6 +358,7 @@ def media(self): if you own the domain which the episodes reside on. If you don't, then you must set :py:attr:`.id` to an appropriate value manually. """ + return self.__media @media.setter def media(self, media): diff --git a/feedgen/tests/test_episode.py b/feedgen/tests/test_episode.py index 4e776da..609ddcf 100644 --- a/feedgen/tests/test_episode.py +++ b/feedgen/tests/test_episode.py @@ -49,6 +49,63 @@ def setUp(self): self.fg = fg + def test_constructor(self): + title = "A constructed episode" + subtitle = "We're using the constructor!" + summary = "In this week's episode, we try using the constructor to " \ + "create a new Episode object." + long_summary = "In this week's episode, we try to use the constructor " \ + "to create a new Episode object. Additionally, we'll " \ + "check whether it actually worked or not. Hold your " \ + "fingers crossed!" + media = Media("http://example.com/episodes/1.mp3", 1425345346, + "audio/mpeg", + datetime.timedelta(hours=1, minutes=2, seconds=22)) + publication_date = datetime.datetime(2016, 6, 7, 13, 37, 0, + tzinfo=pytz.utc) + link = "http://example.com/blog/?i=1" + authors = [Person("John Doe", "johndoe@example.com")] + image = "http://example.com/static/1.png" + explicit = True + is_closed_captioned = False + position = 3 + withhold_from_itunes = True + + ep = Episode( + title=title, + subtitle=subtitle, + summary=summary, + long_summary=long_summary, + media=media, + publication_date=publication_date, + link=link, + authors=authors, + image=image, + explicit=explicit, + is_closed_captioned=is_closed_captioned, + position=position, + withhold_from_itunes=withhold_from_itunes, + ) + + # Time to check if this works + self.assertEqual(ep.title, title) + self.assertEqual(ep.subtitle, subtitle) + self.assertEqual(ep.summary, summary) + self.assertEqual(ep.long_summary, long_summary) + self.assertEqual(ep.media, media) + self.assertEqual(ep.publication_date, publication_date) + self.assertEqual(ep.link, link) + self.assertEqual(ep.authors, authors) + self.assertEqual(ep.image, image) + self.assertEqual(ep.explicit, explicit) + self.assertEqual(ep.is_closed_captioned, is_closed_captioned) + self.assertEqual(ep.position, position) + self.assertEqual(ep.withhold_from_itunes, withhold_from_itunes) + + def test_constructorUnknownKeyword(self): + self.assertRaises(TypeError, Episode, tittel="What is tittel") + self.assertRaises(TypeError, Episode, "This is not a keyword") + def test_checkItemNumbers(self): fg = self.fg From 503fae8ef859abd89f14fa55d1e9f7738153323f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 12:49:56 +0200 Subject: [PATCH 085/200] Remove empty docstring from constructor --- feedgen/episode.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/feedgen/episode.py b/feedgen/episode.py index 7eca443..5fc841a 100644 --- a/feedgen/episode.py +++ b/feedgen/episode.py @@ -61,8 +61,6 @@ class Episode(object): """ def __init__(self, **kwargs): - """ - """ # RSS self.__authors = [] self.summary = None From 674025961933c7caf520f9f3542cc8688c09b03b Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 15:17:28 +0200 Subject: [PATCH 086/200] Test all attributes of Podcast --- feedgen/tests/test_podcast.py | 42 +++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/feedgen/tests/test_podcast.py b/feedgen/tests/test_podcast.py index fbe8469..b7a2b48 100644 --- a/feedgen/tests/test_podcast.py +++ b/feedgen/tests/test_podcast.py @@ -27,11 +27,11 @@ def setUp(self): self.nsItunes = "http://www.itunes.com/dtds/podcast-1.0.dtd" self.feedUrl = "http://example.com/feeds/myfeed.rss" - self.title = 'Some Testfeed' + self.name = 'Some Testfeed' self.author = Person('John Doe', 'john@example.de') - self.linkHref = 'http://example.com' + self.website = 'http://example.com' self.description = 'This is a cool feed!' self.language = 'en' @@ -42,7 +42,8 @@ def setUp(self): self.cloudRegisterProcedure = 'registerProcedure' self.cloudProtocol = 'SOAP 1.1' - self.contributor = {'name':"Contributor Name", 'email': 'Contributor email'} + self.contributor = {'name':"Contributor Name", + 'email': 'Contributor email'} self.copyright = "The copyright notice" self.docs = 'http://www.rssboard.org/rss-specification' self.skipDays = {'Tuesday'} @@ -53,9 +54,13 @@ def setUp(self): self.programname = feedgen.version.name self.webMaster = Person(email='webmaster@example.com') + self.image = "http://example.com/static/podcast.png" + self.owner = self.author + self.complete = True - fg.name = self.title - fg.website = self.linkHref + + fg.name = self.name + fg.website = self.website fg.description = self.description fg.language = self.language fg.cloud = (self.cloudDomain, self.cloudPort, self.cloudPath, @@ -67,25 +72,29 @@ def setUp(self): fg.web_master = self.webMaster fg.feed_url = self.feedUrl fg.explicit = self.explicit + fg.image = self.image + fg.owner = self.owner + fg.complete = self.complete self.fg = fg - def test_baseFeed(self): fg = self.fg - assert fg.name == self.title + assert fg.name == self.name assert fg.authors[0] == self.author assert fg.web_master == self.webMaster - assert fg.website == self.linkHref + assert fg.website == self.website assert fg.description == self.description assert fg.language == self.language assert fg.feed_url == self.feedUrl - + assert fg.image == self.image + assert fg.owner == self.owner + assert fg.complete == self.complete def test_rssFeedFile(self): fg = self.fg @@ -102,9 +111,7 @@ def test_rssFeedString(self): rssString = fg.rss_str(xml_declaration=False) self.checkRssString(rssString) - def checkRssString(self, rssString): - feed = etree.fromstring(rssString) nsRss = self.nsContent nsAtom = "http://www.w3.org/2005/Atom" @@ -112,7 +119,7 @@ def checkRssString(self, rssString): channel = feed.find("channel") assert channel != None - assert channel.find("title").text == self.title + assert channel.find("title").text == self.name assert channel.find("description").text == self.description assert channel.find("lastBuildDate").text != None assert channel.find("docs").text == "http://www.rssboard.org/rss-specification" @@ -132,6 +139,13 @@ def checkRssString(self, rssString): assert channel.find("{%s}link" % nsAtom).get('rel') == 'self' assert channel.find("{%s}link" % nsAtom).get('type') == \ 'application/rss+xml' + assert channel.find("{%s}image" % self.nsItunes).get('href') == \ + self.image + owner = channel.find("{%s}owner" % self.nsItunes) + assert owner.find("{%s}name" % self.nsItunes).text == self.owner.name + assert owner.find("{%s}email" % self.nsItunes).text == self.owner.email + assert channel.find("{%s}complete" % self.nsItunes).text.lower() == \ + "yes" def test_feedUrlValidation(self): self.assertRaises(ValueError, setattr, self.fg, "feed_url", @@ -350,9 +364,9 @@ def test_mandatoryValues(self): if test_property != "description": fg.description = self.description if test_property != "title": - fg.name = self.title + fg.name = self.name if test_property != "link": - fg.website = self.linkHref + fg.website = self.website if test_property != "explicit": fg.explicit = self.explicit try: From 5a7e1f3177bfda1e612f4fdf2dfaf535c9e2c6c6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 16:04:33 +0200 Subject: [PATCH 087/200] Enable setting attributes in Podcast constructor --- doc/user/basic_usage_guide/part_1.rst | 19 +++++++++ doc/user/basic_usage_guide/part_2.rst | 5 ++- feedgen/podcast.py | 39 +++++++++++++++++-- feedgen/tests/test_podcast.py | 55 ++++++++++++++++++++------- 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index e3936da..d59f796 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -103,5 +103,24 @@ Read more: * :attr:`~feedgen.Podcast.complete` * :attr:`~feedgen.Podcast.withhold_from_itunes` +Shortcut for filling in data +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Instead of creating a new :class:`.Podcast` object in one statement, and +populating it with data one statement at a time afterwards, you can create a +new :class:`.Podcast` object and fill it with data in one statement. Simply +use the attribute name as keyword arguments to the constructor:: + + import feedgen + p = feedgen.Podcast( + =, + =, + ... + ) + +Take a look at the :doc:`API Documentation for Podcast ` for a +practical example. + +-------------------------------------------------------------------------------- Next step is :doc:`part_2`. diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index 1fe2a65..a9d7405 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -212,7 +212,8 @@ Shortcut for filling in data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Instead of assigning those values one at a time, you can assign them all in -one go in the constructor. Just use the attribute name as the keyword:: +one go in the constructor – just like you can with Podcast. Just use the +attribute name as the keyword:: Episode( =, @@ -222,4 +223,6 @@ one go in the constructor. Just use the attribute name as the keyword:: See also the example in :doc:`the API Documentation `. +-------------------------------------------------------------------------------- + The final step is :doc:`part_3` diff --git a/feedgen/podcast.py b/feedgen/podcast.py index 00692d0..d9cae5c 100644 --- a/feedgen/podcast.py +++ b/feedgen/podcast.py @@ -8,7 +8,7 @@ :license: FreeBSD and LGPL, see license.* for more details. """ - +from future.utils import iteritems from lxml import etree from datetime import datetime import dateutil.parser @@ -35,10 +35,33 @@ class Podcast(object): * :attr:`~feedgen.Podcast.website` * :attr:`~feedgen.Podcast.description` * :attr:`~feedgen.Podcast.explicit` - """ + There is a **shortcut** you can use when creating new Podcast objects, that + lets you populate the attributes using the constructor. Use keyword + arguments with the **attribute name as keyword** and the desired value as + value:: + + >>> import feedgen + >>> # The following... + >>> p = Podcast() + >>> p.name = "The Test Podcast" + >>> p.website = "http://example.com" + >>> # ...is the same as this: + >>> p = Podcast( + ... name="The Test Podcast", + ... website="http://example.com", + ... ) + + Of course, you can do this for as many (or few) attributes as you like, and + you can still set the attributes afterwards, like always. + + :raises: TypeError if you use a keyword which isn't recognized as an + attribute. ValueError if you use a value which isn't compatible with + the attribute (just like when you assign it manually). + + """ - def __init__(self): + def __init__(self, **kwargs): self.__episodes = [] """The list used by self.episodes.""" self.__episode_class = Episode @@ -191,6 +214,16 @@ def __init__(self): description on iTunes. The subtitle displays best if it is only a few words long, like a short slogan.""" + # Populate the podcast with the keyword arguments + for attribute, value in iteritems(kwargs): + if hasattr(self, attribute): + setattr(self, attribute, value) + else: + raise TypeError("Keyword argument %s (with value %s) doesn't " + "match any attribute in Podcast." % + (attribute, value)) + + @property def episodes(self): """List of episodes that are part of this podcast. diff --git a/feedgen/tests/test_podcast.py b/feedgen/tests/test_podcast.py index b7a2b48..15f1ef8 100644 --- a/feedgen/tests/test_podcast.py +++ b/feedgen/tests/test_podcast.py @@ -25,7 +25,7 @@ def setUp(self): self.nsContent = "http://purl.org/rss/1.0/modules/content/" self.nsDc = "http://purl.org/dc/elements/1.1/" self.nsItunes = "http://www.itunes.com/dtds/podcast-1.0.dtd" - self.feedUrl = "http://example.com/feeds/myfeed.rss" + self.feed_url = "http://example.com/feeds/myfeed.rss" self.name = 'Some Testfeed' @@ -46,14 +46,14 @@ def setUp(self): 'email': 'Contributor email'} self.copyright = "The copyright notice" self.docs = 'http://www.rssboard.org/rss-specification' - self.skipDays = {'Tuesday'} - self.skipHours = {23} + self.skip_days = {'Tuesday'} + self.skip_hours = {23} self.explicit = False self.programname = feedgen.version.name - self.webMaster = Person(email='webmaster@example.com') + self.web_master = Person(email='webmaster@example.com') self.image = "http://example.com/static/podcast.png" self.owner = self.author self.complete = True @@ -67,10 +67,10 @@ def setUp(self): self.cloudRegisterProcedure, self.cloudProtocol) fg.copyright = self.copyright fg.authors.append(self.author) - fg.skip_days = self.skipDays - fg.skip_hours = self.skipHours - fg.web_master = self.webMaster - fg.feed_url = self.feedUrl + fg.skip_days = self.skip_days + fg.skip_hours = self.skip_hours + fg.web_master = self.web_master + fg.feed_url = self.feed_url fg.explicit = self.explicit fg.image = self.image fg.owner = self.owner @@ -78,20 +78,47 @@ def setUp(self): self.fg = fg + def test_constructor(self): + # Overwrite fg from setup + self.fg = Podcast( + name=self.name, + website=self.website, + description=self.description, + language=self.language, + cloud=(self.cloudDomain, self.cloudPort, self.cloudPath, + self.cloudRegisterProcedure, self.cloudProtocol), + copyright=self.copyright, + authors=[self.author], + skip_days=self.skip_days, + skip_hours=self.skip_hours, + web_master=self.web_master, + feed_url=self.feed_url, + explicit=self.explicit, + image=self.image, + owner=self.owner, + complete=self.complete, + ) + # Test that the fields are actually set + self.test_baseFeed() + + def test_constructorUnknownAttributes(self): + self.assertRaises(TypeError, Podcast, naem="Oh, looks like a typo") + self.assertRaises(TypeError, Podcast, "Haha, No Keyword") + def test_baseFeed(self): fg = self.fg assert fg.name == self.name assert fg.authors[0] == self.author - assert fg.web_master == self.webMaster + assert fg.web_master == self.web_master assert fg.website == self.website assert fg.description == self.description assert fg.language == self.language - assert fg.feed_url == self.feedUrl + assert fg.feed_url == self.feed_url assert fg.image == self.image assert fg.owner == self.owner assert fg.complete == self.complete @@ -132,10 +159,10 @@ def checkRssString(self, rssString): assert channel.find("copyright").text == self.copyright assert channel.find("docs").text == self.docs assert self.author.email in channel.find("managingEditor").text - assert channel.find("skipDays").find("day").text in self.skipDays - assert int(channel.find("skipHours").find("hour").text) in self.skipHours - assert self.webMaster.email in channel.find("webMaster").text - assert channel.find("{%s}link" % nsAtom).get('href') == self.feedUrl + assert channel.find("skipDays").find("day").text in self.skip_days + assert int(channel.find("skipHours").find("hour").text) in self.skip_hours + assert self.web_master.email in channel.find("webMaster").text + assert channel.find("{%s}link" % nsAtom).get('href') == self.feed_url assert channel.find("{%s}link" % nsAtom).get('rel') == 'self' assert channel.find("{%s}link" % nsAtom).get('type') == \ 'application/rss+xml' From a6d554b0ba1cced9bf0ff96275948d264b389cc4 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 17:11:48 +0200 Subject: [PATCH 088/200] Rename from feedgen/PodcastGenerator to PodGen --- .gitignore | 4 +- Makefile | 12 +- doc/Makefile | 1 - doc/api.category.rst | 6 +- doc/api.episode.rst | 8 +- doc/api.media.rst | 6 +- doc/api.person.rst | 8 +- doc/api.podcast.rst | 8 +- doc/api.rst | 16 +-- doc/api.util.rst | 2 +- doc/conf.py | 23 ++- doc/index.rst | 24 ++-- doc/user/basic_usage_guide/index.rst | 2 +- doc/user/basic_usage_guide/part_1.rst | 50 +++---- doc/user/basic_usage_guide/part_2.rst | 54 ++++---- doc/user/basic_usage_guide/part_3.rst | 12 +- doc/user/example.rst | 4 +- doc/user/fork.rst | 10 +- doc/user/index.rst | 2 +- doc/user/installation.rst | 4 +- doc/user/introduction.rst | 4 +- {feedgen => podgen}/__init__.py | 0 {feedgen => podgen}/__main__.py | 6 +- {feedgen => podgen}/category.py | 4 +- {feedgen => podgen}/compat.py | 0 {feedgen => podgen}/episode.py | 24 ++-- {feedgen => podgen}/media.py | 14 +- .../not_supported_by_itunes_warning.py | 0 {feedgen => podgen}/person.py | 2 +- {feedgen => podgen}/podcast.py | 54 ++++---- {feedgen => podgen}/tests/__init__.py | 0 {feedgen => podgen}/tests/test_category.py | 2 +- {feedgen => podgen}/tests/test_episode.py | 2 +- {feedgen => podgen}/tests/test_media.py | 2 +- {feedgen => podgen}/tests/test_person.py | 2 +- {feedgen => podgen}/tests/test_podcast.py | 12 +- {feedgen => podgen}/tests/test_util.py | 2 +- {feedgen => podgen}/util.py | 2 +- {feedgen => podgen}/version.py | 10 +- python-feedgen.spec | 131 ------------------ readme.md | 42 +----- setup.py | 24 ++-- 42 files changed, 212 insertions(+), 383 deletions(-) rename {feedgen => podgen}/__init__.py (100%) rename {feedgen => podgen}/__main__.py (96%) rename {feedgen => podgen}/category.py (97%) rename {feedgen => podgen}/compat.py (100%) rename {feedgen => podgen}/episode.py (96%) rename {feedgen => podgen}/media.py (96%) rename {feedgen => podgen}/not_supported_by_itunes_warning.py (100%) rename {feedgen => podgen}/person.py (98%) rename {feedgen => podgen}/podcast.py (96%) rename {feedgen => podgen}/tests/__init__.py (100%) rename {feedgen => podgen}/tests/test_category.py (98%) rename {feedgen => podgen}/tests/test_episode.py (99%) rename {feedgen => podgen}/tests/test_media.py (99%) rename {feedgen => podgen}/tests/test_person.py (98%) rename {feedgen => podgen}/tests/test_podcast.py (98%) rename {feedgen => podgen}/tests/test_util.py (96%) rename {feedgen => podgen}/util.py (99%) rename {feedgen => podgen}/version.py (71%) delete mode 100644 python-feedgen.spec diff --git a/.gitignore b/.gitignore index 043bda1..ff67717 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,9 @@ venv *.pyo *.swp -feedgen/tests/tmp_Atomfeed.xml +podgen/tests/tmp_Atomfeed.xml -feedgen/tests/tmp_Rssfeed.xml +podgen/tests/tmp_Rssfeed.xml tmp_Atomfeed.xml diff --git a/Makefile b/Makefile index 1617e53..a1d45ba 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,8 @@ sdist: doc clean: doc-clean @echo Removing binary files... - @rm -f `find feedgen -name '*.pyc'` - @rm -f `find feedgen -name '*.pyo'` + @rm -f `find podgen -name '*.pyc'` + @rm -f `find podgen -name '*.pyo'` @echo Removing source distribution files... @rm -rf dist/ @rm -f MANIFEST @@ -42,8 +42,8 @@ publish: sdist python setup.py register sdist upload test: - @python -m unittest feedgen.tests.test_podcast feedgen.tests.test_episode \ - feedgen.tests.test_person feedgen.tests.test_media \ - feedgen.tests.test_util feedgen.tests.test_category - python -m feedgen rss > /dev/null + @python -m unittest podgen.tests.test_podcast podgen.tests.test_episode \ + podgen.tests.test_person podgen.tests.test_media \ + podgen.tests.test_util podgen.tests.test_category + python -m podgen rss > /dev/null @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml diff --git a/doc/Makefile b/doc/Makefile index 4c8c168..7508e3b 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -4,7 +4,6 @@ # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build -#SPHINXBUILD = python /home/lars/master-thesis/code/modules/core/venv/bin/sphinx-build PAPER = BUILDDIR = _build diff --git a/doc/api.category.rst b/doc/api.category.rst index 6042b29..662bfe7 100644 --- a/doc/api.category.rst +++ b/doc/api.category.rst @@ -1,5 +1,5 @@ -feedgen.Category -================ +podgen.Category +=============== -.. autoclass:: feedgen.Category +.. autoclass:: podgen.Category :members: diff --git a/doc/api.episode.rst b/doc/api.episode.rst index aad4da8..d1719e5 100644 --- a/doc/api.episode.rst +++ b/doc/api.episode.rst @@ -1,6 +1,6 @@ -=============== -feedgen.Episode -=============== +============== +podgen.Episode +============== -.. autoclass:: feedgen.Episode +.. autoclass:: podgen.Episode :members: diff --git a/doc/api.media.rst b/doc/api.media.rst index d9209c9..6b6bca6 100644 --- a/doc/api.media.rst +++ b/doc/api.media.rst @@ -1,5 +1,5 @@ -feedgen.Media -============= +podgen.Media +============ -.. autoclass:: feedgen.Media +.. autoclass:: podgen.Media :members: diff --git a/doc/api.person.rst b/doc/api.person.rst index e0b4736..e7363df 100644 --- a/doc/api.person.rst +++ b/doc/api.person.rst @@ -1,6 +1,6 @@ -============== -feedgen.Person -============== +============= +podgen.Person +============= -.. autoclass:: feedgen.Person +.. autoclass:: podgen.Person :members: diff --git a/doc/api.podcast.rst b/doc/api.podcast.rst index e58d6b7..f251564 100644 --- a/doc/api.podcast.rst +++ b/doc/api.podcast.rst @@ -1,6 +1,6 @@ -=============== -feedgen.Podcast -=============== +============== +podgen.Podcast +============== -.. autoclass:: feedgen.Podcast +.. autoclass:: podgen.Podcast :members: diff --git a/doc/api.rst b/doc/api.rst index 4080b51..a611d6d 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -9,14 +9,14 @@ Testing You can test the module integration-testing-style by simply executing:: - $ python -m feedgen + $ python -m podgen When working on this project, you should run the unit tests as well as the integration test, like this:: $ make test -The unit tests reside in ``feedgen/tests`` and are written using the +The unit tests reside in ``podgen/tests`` and are written using the :mod:`unittest` module. @@ -26,12 +26,12 @@ API Documentation .. autosummary:: - feedgen.Podcast - feedgen.Episode - feedgen.Person - feedgen.Media - feedgen.Category - feedgen.util + podgen.Podcast + podgen.Episode + podgen.Person + podgen.Media + podgen.Category + podgen.util .. toctree:: :maxdepth: 2 diff --git a/doc/api.util.rst b/doc/api.util.rst index 73fd6fd..ac8dd62 100644 --- a/doc/api.util.rst +++ b/doc/api.util.rst @@ -1,2 +1,2 @@ -.. automodule:: feedgen.util +.. automodule:: podgen.util :members: diff --git a/doc/conf.py b/doc/conf.py index 4eb3703..163c3af 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,7 +11,7 @@ sys.path.insert(0, os.path.abspath('../')) sys.path.insert(0, os.path.abspath('.')) -import feedgen.version +import podgen.version # -- General configuration ----------------------------------------------------- @@ -41,7 +41,7 @@ master_doc = 'index' # General information about the project. -project = u'PodcastGenerator' +project = u'PodGen' copyright = u'2014, Lars Kiesow and 2016, Thorben Dahl' # The version info for the project you're documenting, acts as replacement for @@ -49,9 +49,9 @@ # built documents. # # The short X.Y version. -version = feedgen.version.version_minor_str +version = podgen.version.version_minor_str # The full version, including alpha/beta/rc tags. -release = feedgen.version.version_full_str +release = podgen.version.version_full_str # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -111,7 +111,7 @@ 'gray_3': "rgba(0, 0, 0, 0.1)", 'github_user': 'tobinus', - 'github_repo': 'python-feedgen', + 'github_repo': 'python-podgen', 'github_banner': True, } @@ -120,10 +120,10 @@ # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -html_title = "Podcastgenerator (forked from python-feedgen) Documentation" +html_title = "PodGen Documentation" # A shorter title for the navigation bar. Default is the same as html_title. -html_short_title = "Podcastgenerator" +html_short_title = "PodGen" # The name of an image file (relative to this directory) to place at the top # of the sidebar. @@ -157,7 +157,6 @@ ], '**': [ 'about.html', - 'relations.html', 'navigation.html', 'searchbox.html', 'donate.html', @@ -195,7 +194,7 @@ #html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'pyFeedGen' +htmlhelp_basename = 'pyPodGen' # -- Options for LaTeX output -------------------------------------------------- @@ -214,7 +213,7 @@ # 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', + ('index', 'pyPodGen.tex', u'pyPodGen Documentation', u'Lars Kiesow', 'manual'), ] @@ -244,7 +243,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', + ('index', 'pyPodGen.tex', u'pyPodGen Documentation', [u'Lars Kiesow'], 1) ] @@ -258,7 +257,7 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', + ('index', 'pyPodGen.tex', u'pyPodGen Documentation', u'Lars Kiesow', 'Lernfunk3', 'One line description of project.', 'Miscellaneous'), ] diff --git a/doc/index.rst b/doc/index.rst index 6f3186d..68c0fc9 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,17 +1,11 @@ -================ -PodcastGenerator -================ +====== +PodGen +====== -.. warning:: +Wouldn't it be nice if there was a **clean and simple library** which could help you +**generate podcast RSS feeds** from your Python code? Well, today's your lucky day! - The documentation here represents how things *hopefully* will work once - all the work is done. In the meantime, the :doc:`api` and the :doc:`user/example` - should provide an accurate view of how to use this package. - -Wouldn't it be nice if there was a clean, simple library which could help you -generate podcast RSS feeds from your Python code? Well, today's your lucky day! - - >>> from feedgen import Podcast, Episode, Media + >>> from podgen import Podcast, Episode, Media >>> # Create the Podcast >>> p = Podcast( name="My Awesome Podcast", @@ -21,7 +15,7 @@ generate podcast RSS feeds from your Python code? Well, today's your lucky day! ) >>> # Add some episodes >>> p.episodes += [ - Episode(title="PodcastGenerator rocks!", + Episode(title="PodGen rocks!", media=Media("http://example.org/ep1.mp3", 11932295), summary="I found an awesome library for creating podcasts"), Episode(title="Heard about clint?", @@ -33,7 +27,7 @@ generate podcast RSS feeds from your Python code? Well, today's your lucky day! >>> rss = str(p) You don't need to read the RSS specification, write XML by hand or wrap your -head around ambigous, undocumented APIs. Just provide the data, and PodcastGenerator +head around ambiguous, undocumented APIs. Just provide the data, and PodGen fixes the rest for you! Where to start @@ -41,7 +35,7 @@ Where to start Take a look at the :doc:`user/example` for a larger example, read about :doc:`the project's background ` or refer to -the :doc:`user/basic_usage_guide/index` for a detailed introduction to PodcastGenerator. +the :doc:`user/basic_usage_guide/index` for a detailed introduction to PodGen. Contents -------- diff --git a/doc/user/basic_usage_guide/index.rst b/doc/user/basic_usage_guide/index.rst index 03471fd..24157b1 100644 --- a/doc/user/basic_usage_guide/index.rst +++ b/doc/user/basic_usage_guide/index.rst @@ -1,7 +1,7 @@ Basic usage guide ================= -When using PodcastGenerator, you can divide your program into +When using PodGen, you can divide your program into three phases: .. toctree:: diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index d59f796..e87c6c7 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -6,7 +6,7 @@ Creating a new instance :: - from feedgen import Podcast + from podgen import Podcast p = Podcast() Mandatory properties @@ -19,12 +19,12 @@ Mandatory properties p.website = "https://example.org" p.explicit = True -Those four properties, :attr:`~feedgen.Podcast.name`, -:attr:`~feedgen.Podcast.description`, -:attr:`~feedgen.Podcast.explicit` and -:attr:`~feedgen.Podcast.website`, are actually +Those four properties, :attr:`~podgen.Podcast.name`, +:attr:`~podgen.Podcast.description`, +:attr:`~podgen.Podcast.explicit` and +:attr:`~podgen.Podcast.website`, are actually the only four **mandatory** properties of -:class:`~feedgen.Podcast`. +:class:`~podgen.Podcast`. Image ~~~~~ @@ -33,7 +33,7 @@ A podcast's image is worth special attention:: p.image = "https://example.com/static/example_podcast.png" -.. autoattribute:: feedgen.Podcast.image +.. autoattribute:: podgen.Podcast.image :noindex: Even though the image *technically* is optional, you won't reach people without it. @@ -42,7 +42,7 @@ Optional properties ~~~~~~~~~~~~~~~~~~~ There are plenty of other properties that can be used with -:class:`feedgen.Podcast `: +:class:`podgen.Podcast `: Commonly used @@ -59,12 +59,12 @@ Commonly used Read more: -* :attr:`~feedgen.Podcast.copyright` -* :attr:`~feedgen.Podcast.language` -* :attr:`~feedgen.Podcast.authors` -* :attr:`~feedgen.Podcast.feed_url` -* :attr:`~feedgen.Podcast.category` -* :attr:`~feedgen.Podcast.owner` +* :attr:`~podgen.Podcast.copyright` +* :attr:`~podgen.Podcast.language` +* :attr:`~podgen.Podcast.authors` +* :attr:`~podgen.Podcast.feed_url` +* :attr:`~podgen.Podcast.category` +* :attr:`~podgen.Podcast.owner` Less commonly used @@ -93,15 +93,15 @@ again have very reasonable defaults. Read more: -* :attr:`~feedgen.Podcast.cloud` -* :attr:`~feedgen.Podcast.last_updated` -* :attr:`~feedgen.Podcast.publication_date` -* :attr:`~feedgen.Podcast.skip_days` -* :attr:`~feedgen.Podcast.skip_hours` -* :attr:`~feedgen.Podcast.web_master` -* :attr:`~feedgen.Podcast.new_feed_url` -* :attr:`~feedgen.Podcast.complete` -* :attr:`~feedgen.Podcast.withhold_from_itunes` +* :attr:`~podgen.Podcast.cloud` +* :attr:`~podgen.Podcast.last_updated` +* :attr:`~podgen.Podcast.publication_date` +* :attr:`~podgen.Podcast.skip_days` +* :attr:`~podgen.Podcast.skip_hours` +* :attr:`~podgen.Podcast.web_master` +* :attr:`~podgen.Podcast.new_feed_url` +* :attr:`~podgen.Podcast.complete` +* :attr:`~podgen.Podcast.withhold_from_itunes` Shortcut for filling in data ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -111,8 +111,8 @@ populating it with data one statement at a time afterwards, you can create a new :class:`.Podcast` object and fill it with data in one statement. Simply use the attribute name as keyword arguments to the constructor:: - import feedgen - p = feedgen.Podcast( + import podgen + p = podgen.Podcast( =, =, ... diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index a9d7405..62d920d 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -3,16 +3,16 @@ Adding episodes --------------- To add episodes to a feed, you need to create new -:class:`feedgen.Episode` objects and +:class:`podgen.Episode` objects and append them to the list of entries in the Podcast. That is pretty straight-forward:: - from feedgen import Episode + from podgen import Episode my_episode = Episode() p.episodes.append(my_episode) -There is a convenience method called :meth:`Podcast.add_episode ` -which optionally creates a new instance of :class:`~feedgen.Episode`, adds it to the podcast +There is a convenience method called :meth:`Podcast.add_episode ` +which optionally creates a new instance of :class:`~podgen.Episode`, adds it to the podcast and returns it, allowing you to assign it to a variable:: my_episode = p.add_episode() @@ -42,20 +42,20 @@ well as a short subtitle:: Read more: -* :attr:`~feedgen.Episode.title` -* :attr:`~feedgen.Episode.subtitle` -* :attr:`~feedgen.Episode.summary` -* :attr:`~feedgen.Episode.long_summary` +* :attr:`~podgen.Episode.title` +* :attr:`~podgen.Episode.subtitle` +* :attr:`~podgen.Episode.summary` +* :attr:`~podgen.Episode.long_summary` Enclosing media ^^^^^^^^^^^^^^^ Of course, this isn't much of a podcast if we don't have any -:attr:`~feedgen.Episode.media` attached to it! :: +:attr:`~podgen.Episode.media` attached to it! :: from datetime import timedelta - from feedgen import Media + from podgen import Media my_episode.media = Media("http://example.com/podcast/s01e10.mp3", size=17475653, type="audio/mpeg", # Optional, can be determined @@ -67,7 +67,7 @@ Normally, you must specify how big the **file size** is in bytes (and the MIME type, if the file extension is unknown to iTunes), but PodcastGenerator can send a HEAD request to the URL and retrieve the missing information (file size and type). This is done by calling -:meth:`Media.create_from_server_response ` +:meth:`Media.create_from_server_response ` instead of using the constructor directly. You must pass in the `requests `_ module, so it must be installed! :: @@ -87,7 +87,7 @@ iTunes will use the file extension to determine what type of file it is, without even asking the server. You must therefore make sure your media files have the correct file extension. If you don't care about compatibility with iTunes, you can provide the MIME type yourself. -:meth:`Media.create_from_server_response ` +:meth:`Media.create_from_server_response ` will also fetch the type for you, if it's not specified. The **duration** is also important to include, for your listeners' convenience. @@ -97,8 +97,8 @@ must be an instance of :class:`datetime.timedelta`. Read more about: -* :attr:`feedgen.Episode.media` (the attribute) -* :class:`feedgen.Media` (the class which you use as value) +* :attr:`podgen.Episode.media` (the attribute) +* :class:`podgen.Media` (the class which you use as value) Identifying the episode @@ -115,7 +115,7 @@ That is, given the example above, the id of ``my_episode`` would be An episode's ID should never change. Therefore, **if you don't set id, the media URL must never change either**. -Read more about :attr:`the id attribute `. +Read more about :attr:`the id attribute `. Episode's publication date ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -138,7 +138,7 @@ will get a new episode which appears to have existed for longer than it has. my_episode.publication_date = datetime.datetime(2016, 5, 18, 10, 0, tzinfo=pytz.utc) -Read more about :attr:`the publication_date attribute `. +Read more about :attr:`the publication_date attribute `. The Link @@ -147,7 +147,7 @@ The Link If you're publishing articles along with your podcast episodes, you should link to the relevant article. Examples can be linking to the sound on SoundCloud or the post on your website. Usually, your -listeners expect to find the entirety of the :attr:`~feedgen.Episode.summary` by following +listeners expect to find the entirety of the :attr:`~podgen.Episode.summary` by following the link. :: my_episode.link = "http://example.com/article/2016/05/18/Best-example" @@ -157,7 +157,7 @@ the link. :: If you don't have anything to link to, then that's fine as well. No link is better than a disappointing link. -Read more about :attr:`the link attribute `. +Read more about :attr:`the link attribute `. The Authors @@ -166,12 +166,12 @@ The Authors .. note:: Some of the following attributes (not just authors) correspond to attributes - found in :class:`~feedgen.Podcast`. In such cases, you should only set those + found in :class:`~podgen.Podcast`. In such cases, you should only set those attributes at the episode level if they **differ** from their value at the podcast level. -Normally, the attributes :attr:`Podcast.authors ` -and :attr:`Podcast.web_master ` (if set) are +Normally, the attributes :attr:`Podcast.authors ` +and :attr:`Podcast.web_master ` (if set) are used to determine the authors of an episode. Thus, if all your episodes have the same authors, you should just set it at the podcast level. @@ -184,7 +184,7 @@ You can even have multiple authors:: my_episode.authors = [Person("Joe Bob"), Person("Alice Bob")] -Read more about :attr:`an episode's authors `. +Read more about :attr:`an episode's authors `. Less used attributes @@ -201,11 +201,11 @@ Less used attributes More details: -* :attr:`~feedgen.Episode.image` -* :attr:`~feedgen.Episode.explicit` -* :attr:`~feedgen.Episode.is_closed_captioned` -* :attr:`~feedgen.Episode.position` -* :attr:`~feedgen.Episode.withhold_from_itunes` +* :attr:`~podgen.Episode.image` +* :attr:`~podgen.Episode.explicit` +* :attr:`~podgen.Episode.is_closed_captioned` +* :attr:`~podgen.Episode.position` +* :attr:`~podgen.Episode.withhold_from_itunes` Shortcut for filling in data diff --git a/doc/user/basic_usage_guide/part_3.rst b/doc/user/basic_usage_guide/part_3.rst index 485ac25..6596b71 100644 --- a/doc/user/basic_usage_guide/part_3.rst +++ b/doc/user/basic_usage_guide/part_3.rst @@ -9,28 +9,28 @@ take the final step:: # Print to stdout, just as an example print(rssfeed) -If you're okay with the default parameters of :meth:`feedgen.Podcast.rss_str`, -you can use a shortcut by converting :class:`~feedgen.Podcast` to :obj:`str`:: +If you're okay with the default parameters of :meth:`podgen.Podcast.rss_str`, +you can use a shortcut by converting :class:`~podgen.Podcast` to :obj:`str`:: rssfeed = str(p) # Or let print convert to str for you print(p) -Doing so is the same as calling :meth:`feedgen.Podcast.rss_str` with no +Doing so is the same as calling :meth:`podgen.Podcast.rss_str` with no parameters. .. autosummary:: - ~feedgen.Podcast.rss_str + ~podgen.Podcast.rss_str -You may also write the feed to a file directly, using :meth:`feedgen.Podcast.rss_file`:: +You may also write the feed to a file directly, using :meth:`podgen.Podcast.rss_file`:: fg.rss_file('rss.xml', minimize=True) .. autosummary:: - ~feedgen.Podcast.rss_file + ~podgen.Podcast.rss_file This concludes the basic usage guide. You might want to look at the :doc:`../example` or the :doc:`/api`. diff --git a/doc/user/example.rst b/doc/user/example.rst index 131110d..6fd156b 100644 --- a/doc/user/example.rst +++ b/doc/user/example.rst @@ -2,10 +2,10 @@ Working example =============== -Below is a working example of how you can go about using PodcastGenerator. It +Below is a working example of how you can go about using PodGen. It also shows you how you can use the different properties of Podcast and Episode. -.. literalinclude:: ../../feedgen/__main__.py +.. literalinclude:: ../../podgen/__main__.py :pyobject: main :linenos: diff --git a/doc/user/fork.rst b/doc/user/fork.rst index fa9d087..0e4ee34 100644 --- a/doc/user/fork.rst +++ b/doc/user/fork.rst @@ -22,12 +22,12 @@ separate ways to go about setting multi-value variables, is also a bit confusing Perhaps the biggest problem, though, is the awkwardness that stems from enabling RSS and ATOM feeds through the same API. In case you don't know, ATOM is a "competitor" to RSS, and has many more capabilities than RSS. However, it is -not used for podcasting. If you're curiousSome methods will map an ATOM value to +not used for podcasting. It is confusing because some methods will map an ATOM value to its closest sibling in RSS, some in logical ways (like the ATOM method ``rights`` setting the value of the RSS property ``copyright``) and some differ in subtle ways (like using (ATOM) ``logo`` versus (RSS) ``image``). Other methods are more complex (see ``link``). They're all confusing, though, since changing one property automatically changes another implicitly. -They also cause bugs, since it is so difficult to map your head around how one +They also cause bugs, since it is so difficult to wrap your head around how one interact with another. Removing ATOM fixes all these issues. @@ -70,8 +70,8 @@ bring it there, so it can benefit **everyone**. Summary of changes ------------------ -* ``FeedGenerator`` is renamed to :class:`~feedgen.Podcast` and ``FeedItem`` is accessed - at ``Podcast.Episode`` (or directly: :class:`~feedgen.BaseEpisode`). +* ``FeedGenerator`` is renamed to :class:`~podgen.Podcast` and ``FeedItem`` is accessed + at ``Podcast.Episode`` (or directly: :class:`~podgen.BaseEpisode`). * Support for ATOM removed. * Move from using getter and setter methods to using properties, which you can assign just like you would assign any other property. @@ -91,6 +91,6 @@ Summary of changes * Rename the remaining properties so their names don't necessarily match the RSS elements they map to. Instead, the names should be descriptive and easy to understand. -* Add shorthand for generating the RSS: Just try to converting your :class:`~feedgen.Podcast` +* Add shorthand for generating the RSS: Just try to converting your :class:`~podgen.Podcast` object to :obj:`str`! * Improve the documentation diff --git a/doc/user/index.rst b/doc/user/index.rst index 23ad3c3..9754233 100644 --- a/doc/user/index.rst +++ b/doc/user/index.rst @@ -3,7 +3,7 @@ User Guide ========== -New to PodcastGenerator? This guide will get you up to speed on how this fork +New to PodGen? This guide will get you up to speed on how this fork came to be, its license as well as how to install and start using it. .. toctree:: diff --git a/doc/user/installation.rst b/doc/user/installation.rst index a5a4ca4..5166b95 100644 --- a/doc/user/installation.rst +++ b/doc/user/installation.rst @@ -8,7 +8,7 @@ Installation #. Activate your project's virtualenv. -#. Install the requirements listed in ``requirements.txt`` inside feedgen:: +#. Install the requirements listed in ``requirements.txt`` inside podgen:: pip install -r requirements.txt @@ -19,4 +19,4 @@ This is a pretty bad way to install something, but I haven't had the time to set up a PyPi package yet. Until then, you'd be better off using the original python-feedgen. -.. _GitHub repository: https://github.com/tobinus/python-feedgen/tree/podcastgen +.. _GitHub repository: https://github.com/tobinus/python-podgen/tree/podcastgen diff --git a/doc/user/introduction.rst b/doc/user/introduction.rst index 476a8ac..2b82217 100644 --- a/doc/user/introduction.rst +++ b/doc/user/introduction.rst @@ -41,7 +41,7 @@ and conflicting views on how different RSS elements should be used. It also saves you from reading the RSS specification, the RSS Best Practices and the documentation for iTunes' Podcast Connect. -PodcastGenerator is geared towards developers who aren't super familiar with +PodGen is geared towards developers who aren't super familiar with RSS and XML. If you know exactly how you want the XML to look, then you're better off using a template engine like Jinja2 (even if friends don't let friends touch XML bare-handed). If you just want an easy way to create and @@ -50,7 +50,7 @@ manage your podcasts, use `Podcast Generator ` ------- License ------- -PodcastGenerator is licensed under the terms of both the FreeBSD license and the LGPLv3+. +PodGen 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/feedgen/__init__.py b/podgen/__init__.py similarity index 100% rename from feedgen/__init__.py rename to podgen/__init__.py diff --git a/feedgen/__main__.py b/podgen/__main__.py similarity index 96% rename from feedgen/__main__.py rename to podgen/__main__.py index 861fc28..fce63c0 100644 --- a/feedgen/__main__.py +++ b/podgen/__main__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- ''' - feedgen + podgen ~~~~~~~ :copyright: 2013, Lars Kiesow @@ -32,7 +32,7 @@ def main(): # print_enc is just a custom function which functions like print, # except it deals with byte arrays properly. print_enc ('Usage: %s ( .rss | rss )' % \ - 'python -m feedgen') + 'python -m podgen') print_enc ('') print_enc (' rss -- Generate RSS test output and print it to stdout.') print_enc (' .rss -- Generate RSS test teed and write it to file.rss.') @@ -42,7 +42,7 @@ def main(): # Remember what type of feed the user wants arg = sys.argv[1] - from feedgen import Podcast, Person, Media, Category, htmlencode + from podgen import Podcast, Person, Media, Category, htmlencode # Initialize the feed p = Podcast() p.name = 'Testfeed' diff --git a/feedgen/category.py b/podgen/category.py similarity index 97% rename from feedgen/category.py rename to podgen/category.py index 9dc3fb2..6b8b398 100644 --- a/feedgen/category.py +++ b/podgen/category.py @@ -12,7 +12,7 @@ class Category(object): Example:: - >>> from feedgen.category import Category + >>> from podgen.category import Category >>> c = Category("Music") >>> c.category Music @@ -58,7 +58,7 @@ class Category(object): def __init__(self, category, subcategory=None): """Create new Category object. See the class description of - :class:´~feedgen.category.Category`. + :class:´~podgen.category.Category`. :param category: Category of the podcast. :type category: str diff --git a/feedgen/compat.py b/podgen/compat.py similarity index 100% rename from feedgen/compat.py rename to podgen/compat.py diff --git a/feedgen/episode.py b/podgen/episode.py similarity index 96% rename from feedgen/episode.py rename to podgen/episode.py index 5fc841a..17c6ac1 100644 --- a/feedgen/episode.py +++ b/podgen/episode.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ - feedgen.entry + podgen.entry ~~~~~~~~~~~~~ :copyright: 2013, Lars Kiesow @@ -11,8 +11,8 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.util import formatRFC2822, listToHumanreadableStr -from feedgen.compat import string_types +from podgen.util import formatRFC2822, listToHumanreadableStr +from podgen.compat import string_types from builtins import str from future.utils import iteritems @@ -44,9 +44,9 @@ class Episode(object): To add an episode to a podcast:: - >>> import feedgen - >>> p = feedgen.Podcast() - >>> episode = feedgen.Episode() + >>> import podgen + >>> p = podgen.Podcast() + >>> episode = podgen.Episode() >>> p.episodes.append(episode) You may also replace the last two lines with a shortcut:: @@ -68,9 +68,9 @@ def __init__(self, **kwargs): XHTML parsers. If your summary isn't fit to be parsed as XHTML, you can use - :py:func:`feedgen.htmlencode` to fix the text, like this:: + :py:func:`podgen.htmlencode` to fix the text, like this:: - >>> ep.summary = feedgen.htmlencode("We spread lots of love <3") + >>> ep.summary = podgen.htmlencode("We spread lots of love <3") >>> ep.summary We spread lots of love <3 @@ -83,7 +83,7 @@ def __init__(self, **kwargs): self.long_summary = None """A long (read: full) summary, which supplements the shorter - :attr:`~feedgen.Episode.summary`. + :attr:`~podgen.Episode.summary`. This attribute should be seen as a full, longer variation of summary if summary exists. Even then, the long_summary should be @@ -276,7 +276,7 @@ def rss_entry(self): @property def authors(self): - """List of :class:`~feedgen.Person` that contributed to this + """List of :class:`~podgen.Person` that contributed to this episode. The authors don't need to have both name and email set. The names are @@ -289,7 +289,7 @@ def authors(self): Any value you assign to authors will be automatically converted to a list, but only if it's iterable (like tuple, set and so on). It is an - error to assign a single :class:`~feedgen.Person` object to this + error to assign a single :class:`~podgen.Person` object to this attribute:: >>> # This results in an error @@ -345,7 +345,7 @@ def publication_date(self, publication_date): @property def media(self): - """Get or set the :class:`~feedgen.Media` object that is attached + """Get or set the :class:`~podgen.Media` object that is attached to this episode. Note that if :py:attr:`.id` is not set, the enclosure's url is used as diff --git a/feedgen/media.py b/podgen/media.py similarity index 96% rename from feedgen/media.py rename to podgen/media.py index 1d84362..e5fdb06 100644 --- a/feedgen/media.py +++ b/podgen/media.py @@ -2,8 +2,8 @@ from future.moves.urllib.parse import urlparse import datetime -from feedgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning -from feedgen import version +from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from podgen import version class Media(object): @@ -27,7 +27,7 @@ class Media(object): .. note:: - A warning called :class:`~feedgen.not_supported_by_itunes_warning.NotSupportedByItunesWarning` + A warning called :class:`~podgen.not_supported_by_itunes_warning.NotSupportedByItunesWarning` will be issued if your URL or type isn't compatible with iTunes. See the Python documentation for more details on :mod:`warnings`. @@ -171,11 +171,11 @@ def type(self): .. note:: If you leave out type when creating a new Media object, the - type will be auto-detected from the :attr:`~feedgen.media.Media.url` + type will be auto-detected from the :attr:`~podgen.media.Media.url` attribute. However, this won't happen automatically other than during initialization. If you want to autodetect type when assigning a new value to url, you should use - :meth:`~feedgen.media.Media.get_type`. + :meth:`~podgen.media.Media.get_type`. """ return self._type @@ -196,7 +196,7 @@ def get_type(self, url): Example:: - >>> from feedgen.media import Media + >>> from podgen.media import Media >>> m = Media("http://example.org/1.mp3", 136532744) >>> # The type was detected from the url: >>> m.type @@ -279,7 +279,7 @@ def create_from_server_response(cls, requests, url, size=None, type=None, Example (assuming the server responds with Content-Length: 252345991 and Content-Type: audio/mpeg):: - >>> from feedgen.media import Media + >>> from podgen.media import Media >>> import requests # from requests package >>> # Assume an episode is hosted at example.com >>> m = Media.create_from_server_response(requests, diff --git a/feedgen/not_supported_by_itunes_warning.py b/podgen/not_supported_by_itunes_warning.py similarity index 100% rename from feedgen/not_supported_by_itunes_warning.py rename to podgen/not_supported_by_itunes_warning.py diff --git a/feedgen/person.py b/podgen/person.py similarity index 98% rename from feedgen/person.py rename to podgen/person.py index 03d6b40..3a93f2f 100644 --- a/feedgen/person.py +++ b/podgen/person.py @@ -22,7 +22,7 @@ class Person(object): Example of use:: - >>> from feedgen import Person + >>> from podgen import Person >>> Person("John Doe") Person(name=John Doe, email=None) >>> Person(email="johndoe@example.org") diff --git a/feedgen/podcast.py b/podgen/podcast.py similarity index 96% rename from feedgen/podcast.py rename to podgen/podcast.py index d9cae5c..7a1b737 100644 --- a/feedgen/podcast.py +++ b/podgen/podcast.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ - feedgen.feed + podgen.feed ~~~~~~~~~~~~ :copyright: 2013, Lars Kiesow @@ -13,17 +13,17 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.episode import Episode -from feedgen.util import ensure_format, formatRFC2822, listToHumanreadableStr -from feedgen.person import Person -import feedgen.version +from podgen.episode import Episode +from podgen.util import ensure_format, formatRFC2822, listToHumanreadableStr +from podgen.person import Person +import podgen.version import sys -from feedgen.compat import string_types +from podgen.compat import string_types import collections import inspect -_feedgen_version = feedgen.version.version_str +_feedgen_version = podgen.version.version_str class Podcast(object): @@ -31,17 +31,17 @@ class Podcast(object): The following attributes are mandatory: - * :attr:`~feedgen.Podcast.name` - * :attr:`~feedgen.Podcast.website` - * :attr:`~feedgen.Podcast.description` - * :attr:`~feedgen.Podcast.explicit` + * :attr:`~podgen.Podcast.name` + * :attr:`~podgen.Podcast.website` + * :attr:`~podgen.Podcast.description` + * :attr:`~podgen.Podcast.explicit` There is a **shortcut** you can use when creating new Podcast objects, that lets you populate the attributes using the constructor. Use keyword arguments with the **attribute name as keyword** and the desired value as value:: - >>> import feedgen + >>> import podgen >>> # The following... >>> p = Podcast() >>> p.name = "The Test Podcast" @@ -255,7 +255,7 @@ def episode_class(self): Example of use:: >>> # Create new podcast - >>> from feedgen import Podcast, Episode + >>> from podgen import Podcast, Episode >>> p = Podcast() >>> # Normal way of creating new episodes >>> episode1 = Episode() @@ -303,14 +303,14 @@ def add_episode(self, new_episode=None): >>> entry.title('First feed entry') 'First feed entry' >>> # You may also provide an episode object yourself: - >>> another_entry = feedgen.add_episode(feedgen.Episode()) + >>> another_entry = feedgen.add_episode(podgen.Episode()) >>> another_entry.title('My second feed entry') 'My second feed entry' Internally, this method creates a new instance of - :attr:`~feedgen.Episode.episode_class`, which means you can change what + :attr:`~podgen.Episode.episode_class`, which means you can change what type of objects are created by changing - :attr:`~feedgen.Episode.episode_class`. + :attr:`~podgen.Episode.episode_class`. """ if new_episode is None: @@ -618,7 +618,7 @@ def set_generator(self, generator=None, version=None, uri=None, :param version: (Optional) Version of the software, as a tuple. :param uri: (Optional) URI the software can be found. :param exclude_feedgen: (Optional) Set to True to disable the mentioning - of the python-feedgen library. + of the python-podgen library. .. seealso:: @@ -638,20 +638,20 @@ def _program_name_to_str(self, generator=None, version=None, uri=None): @property def _feedgen_generator_str(self): return self._program_name_to_str( - feedgen.version.name, - feedgen.version.version_full, - feedgen.version.website + podgen.version.name, + podgen.version.version_full, + podgen.version.website ) @property def authors(self): - """List of :class:`~feedgen.Person` that are responsible for this + """List of :class:`~podgen.Person` that are responsible for this podcast's editorial content. Any value you assign to authors will be automatically converted to a list, but only if it's iterable (like tuple, set and so on). It is an - error to assign a single :class:`~feedgen.person.Person` object to this + error to assign a single :class:`~podgen.person.Person` object to this attribute:: >>> # This results in an error @@ -724,7 +724,7 @@ def skip_hours(self): For example, to skip hours between 18 and 7:: - >>> from feedgen import Podcast + >>> from podgen import Podcast >>> p = Podcast() >>> p.skip_hours = set(range(18, 24)) >>> p.skip_hours @@ -754,7 +754,7 @@ def skip_days(self): For example, to skip the weekend:: - >>> from feedgen import Podcast + >>> from podgen import Podcast >>> p = Podcast() >>> p.skip_days = {"Friday", "Saturday", "sunday"} >>> p.skip_days @@ -778,7 +778,7 @@ def skip_days(self, days): @property def web_master(self): - """The :class:`~feedgen.Person` responsible for + """The :class:`~podgen.Person` responsible for technical issues relating to the feed. """ return self.__web_master @@ -796,7 +796,7 @@ def category(self): """The iTunes category, which appears in the category column and in iTunes Store Browser. - Use the :class:`feedgen.Category` class. + Use the :class:`podgen.Category` class. """ return self.__category @@ -872,7 +872,7 @@ def complete(self, complete): @property def owner(self): - """The :class:`~feedgen.Person` who owns this podcast. iTunes + """The :class:`~podgen.Person` who owns this podcast. iTunes will use this information to contact the owner of the podcast for communication specifically about the podcast. It will not be publicly displayed, but it will be in the feed source. diff --git a/feedgen/tests/__init__.py b/podgen/tests/__init__.py similarity index 100% rename from feedgen/tests/__init__.py rename to podgen/tests/__init__.py diff --git a/feedgen/tests/test_category.py b/podgen/tests/test_category.py similarity index 98% rename from feedgen/tests/test_category.py rename to podgen/tests/test_category.py index 2d5425c..1af4ad6 100644 --- a/feedgen/tests/test_category.py +++ b/podgen/tests/test_category.py @@ -1,6 +1,6 @@ import unittest -from feedgen import Category +from podgen import Category class TestCategory(unittest.TestCase): diff --git a/feedgen/tests/test_episode.py b/podgen/tests/test_episode.py similarity index 99% rename from feedgen/tests/test_episode.py rename to podgen/tests/test_episode.py index 609ddcf..7c9082e 100644 --- a/feedgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -9,7 +9,7 @@ import unittest from lxml import etree -from feedgen import Person, Media, Podcast, htmlencode, Episode +from podgen import Person, Media, Podcast, htmlencode, Episode import datetime import pytz from dateutil.parser import parse as parsedate diff --git a/feedgen/tests/test_media.py b/podgen/tests/test_media.py similarity index 99% rename from feedgen/tests/test_media.py rename to podgen/tests/test_media.py index 06fe1bc..e2fcd8c 100644 --- a/feedgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -3,7 +3,7 @@ import warnings from datetime import timedelta -from feedgen import Media, NotSupportedByItunesWarning +from podgen import Media, NotSupportedByItunesWarning class TestMedia(unittest.TestCase): diff --git a/feedgen/tests/test_person.py b/podgen/tests/test_person.py similarity index 98% rename from feedgen/tests/test_person.py rename to podgen/tests/test_person.py index d8f428c..430203c 100644 --- a/feedgen/tests/test_person.py +++ b/podgen/tests/test_person.py @@ -1,5 +1,5 @@ import unittest -from feedgen import Person +from podgen import Person class TestPerson(unittest.TestCase): def setUp(self): diff --git a/feedgen/tests/test_podcast.py b/podgen/tests/test_podcast.py similarity index 98% rename from feedgen/tests/test_podcast.py rename to podgen/tests/test_podcast.py index 15f1ef8..65b49f6 100644 --- a/feedgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -10,8 +10,8 @@ import unittest from lxml import etree -from feedgen import Person, Category, Podcast -import feedgen.version +from podgen import Person, Category, Podcast +import podgen.version import datetime import dateutil.tz import dateutil.parser @@ -51,7 +51,7 @@ def setUp(self): self.explicit = False - self.programname = feedgen.version.name + self.programname = podgen.version.name self.web_master = Person(email='webmaster@example.com') self.image = "http://example.com/static/podcast.png" @@ -183,14 +183,14 @@ def test_generator(self): software_version = (1, 0) software_url = "http://example.com/awesomesoft/" - # Using set_generator, text includes python-feedgen + # Using set_generator, text includes python-podgen self.fg.set_generator(software_name) rss = self.fg._create_rss() generator = rss.find("channel").find("generator").text assert software_name in generator assert self.programname in generator - # Using set_generator, text excludes python-feedgen + # Using set_generator, text excludes python-podgen self.fg.set_generator(software_name, exclude_feedgen=True) generator = self.fg._create_rss().find("channel").find("generator").text assert software_name in generator @@ -204,7 +204,7 @@ def test_generator(self): assert str(software_version[1]) in generator assert software_url in generator - # Using generator directly, text excludes python-feedgen + # Using generator directly, text excludes python-podgen self.fg.generator = software_name generator = self.fg._create_rss().find("channel").find("generator").text assert software_name in generator diff --git a/feedgen/tests/test_util.py b/podgen/tests/test_util.py similarity index 96% rename from feedgen/tests/test_util.py rename to podgen/tests/test_util.py index 9068bc0..61c5562 100644 --- a/feedgen/tests/test_util.py +++ b/podgen/tests/test_util.py @@ -1,5 +1,5 @@ import unittest -from feedgen import util +from podgen import util class TestUtil(unittest.TestCase): diff --git a/feedgen/util.py b/podgen/util.py similarity index 99% rename from feedgen/util.py rename to podgen/util.py index 50fc14b..7c3aab3 100644 --- a/feedgen/util.py +++ b/podgen/util.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ - feedgen.util + podgen.util ~~~~~~~~~~~~ This file contains helper functions for the feed generator module. diff --git a/feedgen/version.py b/podgen/version.py similarity index 71% rename from feedgen/version.py rename to podgen/version.py index a6211bf..b492c7d 100644 --- a/feedgen/version.py +++ b/podgen/version.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ - feedgen.version + podgen.version ~~~~~~~~~~~~~~~ :copyright: 2013-2015, Lars Kiesow @@ -9,11 +9,11 @@ """ -'Version of python-feedgen represented as tuple' +'Version of python-podgen represented as tuple' version = (0, 3, 2) -'Version of python-feedgen represented as string' +'Version of python-podgen represented as string' version_str = '.'.join([str(x) for x in version]) version_major = version[:1] @@ -25,7 +25,7 @@ version_full_str = '.'.join([str(x) for x in version_full]) 'Name of this project' -name = "python-feedgen (podcastgen)" +name = "python-podgen (podcastgen)" 'Website of this project' -website = "https://github.com/tobinus/python-feedgen/tree/podcastgen" +website = "https://github.com/tobinus/python-podgen/tree/podcastgen" diff --git a/python-feedgen.spec b/python-feedgen.spec deleted file mode 100644 index f6a7d46..0000000 --- a/python-feedgen.spec +++ /dev/null @@ -1,131 +0,0 @@ -%define srcname feedgen - -Name: python-%{srcname} -Version: 0.3.2 -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) - - -BuildArch: noarch -BuildRequires: python2-devel -BuildRequires: python-setuptools -BuildRequires: python3-devel -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 -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 -pushd python2 -%{__python2} setup.py build -popd -pushd python3 -%{__python3} setup.py build -popd - - -%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 - - -%files -%defattr(-,root,root,-) -%license python2/license.* -%doc python2/docs/* -%{python2_sitelib}/* - - -%files -n python3-%{srcname} -%defattr(-,root,root,-) -%license python3/license.* -%doc python3/docs/* -%{python3_sitelib}/* - - -%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 - -* 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 diff --git a/readme.md b/readme.md index 5cd4e60..2418d43 100644 --- a/readme.md +++ b/readme.md @@ -1,13 +1,11 @@ -============================================== -Feedgenerator (forked) - Podcasting for humans -============================================== +=================================== +PodGen (forked from python-feedgen) +=================================== Ignore: [![Build Status](https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master) ](https://travis-ci.org/lkiesow/python-feedgen) -**Note: This document is in the process of being rewritten.** - This module can be used to generate podcast feeds in RSS format. It is licensed under the terms of both, the FreeBSD license and the LGPLv3+. @@ -16,9 +14,9 @@ at license.bsd and license.lgpl. More details about the project: -- Repository: https://github.com/lkiesow/python-feedgen +- Repository: https://github.com/tobinus/python-podgen - Documentation: http://lkiesow.github.io/python-feedgen/ -- Python Package Index: https://pypi.python.org/pypi/feedgen/ +- Python Package Index: https://pypi.python.org/pypi/podgen/ ------------ @@ -29,36 +27,6 @@ Currently, you'll need to clone this repository, and create a virtualenv and install lxml and dateutils. -------------- -Create a Feed -------------- - -To create a feed simply instantiate the Podcast class and insert some -data:: - - >>> from feedgen.feed import Podcast - >>> fg = Podcast() - >>> fg.name('Some Testfeed') - >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) - >>> fg.website( href='http://example.com', rel='alternate' ) - >>> fg.image('http://ex.com/logo.jpg') - >>> fg.description('This is a cool feed!') - >>> fg.website( href='http://larskiesow.de/test.atom') - >>> 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: - -- 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:: - - >>> 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'}, ...]) - ---------- Known bugs ---------- diff --git a/setup.py b/setup.py index aed3202..bba2731 100755 --- a/setup.py +++ b/setup.py @@ -2,17 +2,17 @@ # -*- coding: utf-8 -*- from distutils.core import setup -import feedgen.version +import podgen.version setup( - name = 'feedgen', - packages = ['feedgen', 'feedgen/ext'], - version = feedgen.version.version_full_str, - description = 'Feed Generator (ATOM, RSS, Podcasts)', + name = 'podgen', + packages = ['podgen'], + version = podgen.version.version_full_str, + description = 'Generating podcasts with Python should be easy!', author = 'Lars Kiesow', author_email = 'lkiesow@uos.de', url = 'http://lkiesow.github.io/python-feedgen', - keywords = ['feed','ATOM','RSS','podcast'], + keywords = ['feed','RSS','podcast','iTunes'], license = 'FreeBSD and LGPLv3+', install_requires = ['lxml', 'dateutils'], classifiers = [ @@ -26,7 +26,6 @@ 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', 'Topic :: Communications', 'Topic :: Internet', @@ -35,12 +34,13 @@ 'Topic :: Text Processing :: Markup :: XML' ], long_description = '''\ -Feedgenerator -============= +PodGen +====== -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 easily generate Podcasts. It is designed so you +don't need to read up on how RSS and iTunes functions – it just works! + +See the documentation at .... 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 From cafb2b1b3c6ba5f9fa52b927823afeced9b80ddf Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 17:21:31 +0200 Subject: [PATCH 089/200] Don't test Python2 for now, add Travis button --- .travis.yml | 4 ++-- readme.md | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 295bc63..0459ca1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,8 @@ language: python python: - - "2.6" - - "2.7" +# - "2.6" +# - "2.7" - "3.3" - "3.4" diff --git a/readme.md b/readme.md index 2418d43..76e3540 100644 --- a/readme.md +++ b/readme.md @@ -2,9 +2,7 @@ PodGen (forked from python-feedgen) =================================== -Ignore: -[![Build Status](https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master) -](https://travis-ci.org/lkiesow/python-feedgen) +[![Build Status](https://travis-ci.org/tobinus/python-podgen.svg?branch=master)](https://travis-ci.org/tobinus/python-podgen) This module can be used to generate podcast feeds in RSS format. From bceeda22131f0ab9a48a2d4f41833cd6debf5047 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 18:30:51 +0200 Subject: [PATCH 090/200] Work around warnings.catch_warnings bug in Python < 3.4 --- .travis.yml | 1 + podgen/tests/test_media.py | 6 ++++-- readme.md | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0459ca1..c010786 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: # - "2.7" - "3.3" - "3.4" + - "3.5" before_install: pip install --quiet -r requirements.txt diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index e2fcd8c..d0f8ce4 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -13,7 +13,10 @@ def setUp(self): self.type = "audio/mpeg" self.expected_type = ("audio/mpeg3", "audio/x-mpeg-3", "audio/mpeg") self.duration = timedelta(hours=1, minutes=32, seconds=44) - warnings.simplefilter("ignore") + warnings.simplefilter("always") + def noop(*args, **kwargs): + pass + warnings.showwarning = noop def test_constructorOneArgument(self): m = Media(self.url) @@ -125,7 +128,6 @@ def test_anyExtensionAllowedWithType(self): def test_warningGivenIfNotSupportedByItunes(self): with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") self.assertEqual(len(w), 0) # Use a type not supported by itunes self.test_anyExtensionAllowedWithType() diff --git a/readme.md b/readme.md index 76e3540..d43f218 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,8 @@ PodGen (forked from python-feedgen) [![Build Status](https://travis-ci.org/tobinus/python-podgen.svg?branch=master)](https://travis-ci.org/tobinus/python-podgen) -This module can be used to generate podcast feeds in RSS format. +This module can be used to generate podcast feeds in RSS format, and is +compatible with Python 3.3+. 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 From c5d7b42e3a51320c39262048f87b6cd45aa143a4 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 6 Jul 2016 21:55:36 +0200 Subject: [PATCH 091/200] Add buttons, add logo to documentation --- doc/_static/favicon.ico | Bin 0 -> 1150 bytes doc/_static/favicon.xcf | Bin 0 -> 2508 bytes doc/_static/logo.png | Bin 0 -> 97354 bytes doc/conf.py | 12 +++++++----- doc/index.rst | 7 +++++++ readme.md | 2 +- 6 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 doc/_static/favicon.ico create mode 100644 doc/_static/favicon.xcf create mode 100644 doc/_static/logo.png diff --git a/doc/_static/favicon.ico b/doc/_static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3a79916b91fb13023348746fbe8b85cf6e578ad5 GIT binary patch literal 1150 zcmai!u?@md3`8A?0a8&SO2(q3Wr8pRb5St@J5YaoK@S}!KO}N|@!i=@q$FaW{|9L*u!( z64@Uk&ar%gY(IW$CgHh{w2?27&sMf@pMrmi?j@8xIGmoG3@0;uQ*!Ug>2NR`9`(N; z&z|?6e)aV?%G~qN@yQ?=9wevZBgH^F+drF~9}oLy)8p~cgYNz)Ih-C(Py72`9h@f5 z2K|R$JoL{7Wyk-m1@G^|+tS!XDqiZ%qW&ePLei&pJw4Mr=w=@)G9=5{RjLH>OU zqmH>KMB0e67UH77+ht^dy^G8uiQabY%9I}fUeuDUFGM)O1=`W_gnEqn=LtIRM=rT6?i_Kz*UixcH=>X6ou2WL< zCna1+%{J+fTCKPgmPM6&MB8$SosOX)AAKg6Z2DMOvq%=ub+xkeY4MA+=g*DJ+XB_lUOTY+-o3r5()6daI>jN`G>E zf7P~?6$M#ByWxgbK}Bnowd`6Rht}GyG(Gw_sRq5MO&_60d6n&1P|;fz+LgywT+x3m z9r7NSBhO)h7Jsboq{Y4u>yX{1Rp;9m=Skv!74MQ&V(TVWt+6ir1&p3GR)v4mp(Ymo zDW6-LaVs)zMaHejxD|-YxE1I!)2+zNGKpS3@y3)604=gQCB-K$C7f6Vhg2m32SKSD zEwjk7h{f~EMORsbmqp^A(AOrHv+LCyq- z%b5UO=9(Zk%O!f5pfRNbK#ROiNil(xaN->-Q@;TTGaM%Q-bS~Vark|i-f`ZcuCMcKyaRn2g%fgtTFe@Z_nV>PH13-(S zPDwF=lyG7uJXDnw93-c1x-3#FQWnoqmtGYqUXhB!LSLI)&c`axB?2uxcS&BI zw~4$>FQj+Q1>$KM=Pz;R$2!!+c7BpSclJB7K&tmPue#OE9JO1O^J;FtO6}I6F1Gy* DdrAVX literal 0 HcmV?d00001 diff --git a/doc/_static/logo.png b/doc/_static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8270619b436ea6deac14e8f5fe59da0f1a44d0af GIT binary patch literal 97354 zcmd3Ni8qw(`}ku^#ZVC@@eOAE&mN0pK4%`|fR%fRUwf|LR-^^y=ZZe9-;a=WI+uW`>3r(yo5EtNrUdPomnt zf}eNKsm0`F-c1@h^SM1G#a{dI(zt--#WRV=;YCBYZ{L15u_)WTn&`xH>^vs5eqbcH zxZakq7=%~5RPD4g)-$r$(6|`d;Lik+@&C!6_`Z{;0f6|R5??v`?XW?wVp-rYBNO-D zo^IoY6>i({AGKQve%1V==5Ro&L=QDc4R_tHS@qGUB~aZ!?pb_4j^=3&0E${A=Ya62 zQd8P5Y8Cbcc3JC&9KI-Y=uv=r&0=h?I3geJro{?`Kb8Z4q%@}qJPCnXr4p&030^O+ z_uE_3rt!9s6U3|N)MxS8-K|oM)~5&9wo^K+2SHVc&_HrRZKb`|XxSl!KYa7@s+`(>&(`shTFh*C>$Hq{1 zP=#gy;P~Q*5FwJO=I!0y8p6vz|7h^_fM5)aZC*y6_oD$H2S9#iVV?4Ie%F6);j*IJq5sDG)|)Ol(W zIEbS`tW>MFIs)OXWmuJJl{%7_rq8b8hDL(#EkbGS~30;1ISOm3~>UoH}@2l;0<2QD>E;iz7~~apOfd!v_&u;oQD8% zjsQg5u))u)8)x=RmH0U%gT+lm3npyQw$OK@yrb_Ls;1kXLnR>|K7pzwK>Au2{@SfD z&7;qsq0huX-@H+OYt{HYxW8=jV`e;7T6lW9wwgr0cQE_|lQpGkVi_0uHFNCIvxEO= zxm3Y7kcRB0SOGH4!*aZA(NB^>7_pYL_pzb1Qm=u9pai(9w0>xAYpvYY*gONCwciir zm*0zO5*Ac{bo1j#*a~TDaIe}!e5IT^4c?z^u#q|gAo(N|Spd%Z_k`t&5DN7*Mx--- zHkOKmwa&N^l3H*0RxZzR^bXoo;o))4McqXQ#dhr*`Phn!On_q)q%GoD5tfTevhH|k zx)NY1$AMTzwS+gqIxI(Fc++ zPP5i?@Gn}3YujGl=^2EYjE!q)f(;1n$@6A$7l`!sX3g@!JXGPa&T&>&=%SpSjdq;Q zU1mUcn)MWbl&I0twA)?7_z7%dE6;R1kj6KRJu*3H8XN3yZ8{D|_$GN%bx0a8&32hz-~W0m0stgsj}Cw@AsB9&!JNJ+dbK3FA@qnx zKK-p|Jg`IMMyD7;wl%dIO%>TCFSJ?p_ioLyz|dJ?S0SbSl>7yRyHuK@o}f%R^}B4rz6az@4=>=k~!) zQKLxxXYbtZ4#v6{04^PAF|QR5PGc!pF7vjV8H0}=9k^?ro{yy7sAAXc%mOcA+2zmQ z2qNFuTh+*2wD1VY$swo$`uUQm>9R43S&Q%;&;P&$@3;B;tp&zDGo@L z;-ZxS$X|b=M`1#HJHFlo8x@v<)bmI`mXBhUZrn0X9FgrgR1@5FqdIhisJXTaq6N96 zSsVu|VK3i@7LcB#?w_yCQ)vuxqZQO}9wG6y+JPDf{)+Or>x+VZQjPea$0$opZk>rt zTqB`j^zZJeIWfHbnYs-63dYXkIMuu_ zZ53r1GMqFC(=){BKy-6Newg|4B;OHAWs4eiwOl^#3IajF+ECUCc0w^iDf!lOp|3&; zNOd!Y0XCz((}BUw+z_z)4?FO_rA5@64M7o>!z3yb=!ZF%5-yCT6iirKkNuQUu{EvF zQ|awxfq(}s4AtsXDsv5iT^pJV*pE}4InRNd@~~uB&6nC)ukmnvj~Z@ydY=I#Vk3?k*K*O+?-N5|55VrftZPXX&PpT zuD4SfDLQ+j8>?={AW%cW0nFKy3pl}%-F<76g-+FchzkuvmBySu!oe`G;wA()zqk&5 zh4SqHKUDYywhyn*f;lh)$BeO`Qn$iLLAuRcUJxwuSP_23nqfnmaI95mV}m88i+Y+x zIQ;oY)dEjQb$!|}z$GL01YIG}6zNg<&DQ-Vw9izm<6(H=VUdXHHsu&GV?2jGP3@4X z|FrQQ9L4SlI6}Z?RybT098vX*D~V14rC?Izdyv5zFxI;qRln$2i<=mHe|eOn5huB@ z-8RgvQ99VL;?R;DCkY^Zjt{1`l<2Y$G9S=27ChR4wG?`Mbbrw@R>@@1sV>DkgtqA_ zu6a_nbQ~*gDvHNDyYOZXn%+m;pKX2sK? zhH}yQytcng^~VasWFS!Yno)QUP91dTPZ>nC!xNq7ufxbq!+1K4Dhx(-h4$?>QCG2} zLKYBgwa+zdFg(m0ZQmBhw20flZs*NiA(s!4f&yqg;7y%L^ehiZfM0`2LBPMT)HSLC z=+d>NU&d`}fLlou?gaWhQK-8#5{B=7nN`4n9gQ7^#z9&}nh)pFv%%W*<@;%arrAZI zUtcqKmcNIIhBRPRP#8+GG;YFVeEc;tB(HblIYB7R;CS)*@^f`ITrYMkYi-kpN!`gXhKL~FRjVQu z3=g;NC%VKjk$;=@c?(d21-a(ZD?V-Hf|giSp@$Gyn+pN6#ZyL9ZXl@){1yn8C=H`k zfxBI^o^usYvOt-UxBx-Ty6DmBsId|@K(;BGNbjer4}a$*Z;s$|TH6NGp(j#dz=bC7 z9Vy-N_Y~-#|14*3oO%{y2nIc1As5wzX~rc~bUF6z{-)YtpcFh@uf~`rYFzoz8X;_3 z|B+OGff;#XIG^4@bvlBw_B}k3_!`9zXEVCFL?ji_x2X#-wa-jQiBW7&2(8A^H+e%; zzMKN-4|&dVyjSCIk%U=MmNi3%R^S?}$wIz?N~F@j+%9+9$}7-lGnxZnh{=(|rU z8iubb+p2AEGiGiTL#iN(EnC!+NUm@AvMAVB*vjj?fUJyv(Lv6U)E7cfv|Xwf_R`$! zC5V&G0uU?{47w<@$?S`!#b7=NU*lv0;#*(AoTNj_hE*SVqnpi7XBQs0uQakj;yLGi z7xnw1q!^*#pipJ#6hLk%DiAS37g!7~oy{UhsfU%0w0c9l>*lk}@nyyNOj6=-^_)zA zWB~))i0}4LjO$KT+6;i|MdZUJtNOwmjo){qPstS6ybkRIh(nddS#Wgj@Yu}$eatBc zW?cZ{ebT^9d6OGRQGaQ=>b}m!p0!HLp*muqaDeo=6&ArCq2YG*27y-%(5+8L zgxO^FE%9db6o|ObLoD(QtV&Ndt^UA-zE+J91{}N7s3o9Jm%e2J0&Xd!0x|X7!n8hw zi9bXXzs0W#NS^Q~On^<<8?xDpE!1ud6he_V{0OgXKC(^tKUKK}NSfA~$`tAs4k*;K z&2?bs;MX|ML0a4y%cXy1mZWxLLu>8W6#(L`B{Q`$mHY)sl`!Izrawf=y-gwTGLKD* zwJF1+BCeVVIXsdZq*qb7a823vk=oBwkh|&Z`9XDnvCrs|WHy#tU_fV$4$8g1ren7{ zM41cHt(IS*y5dypNEP;Fv>-4PeHP@wm8Ca4-#7~%B8so;Lbs&*Y!J&X zP$d{XGntIr5GPiXs_Ce8W*}~%D59cHG#JOVhOYODd*k*ZC4DC(Lf#8oGWf#VS` z_l6|?umvFV20uat#o2l{lm~s9g2d(sYT`Do5Y0$+L2mDYzAoZO(#x|y|zIRoeGn^@P1Xr}PXNF23U@BY_acF^vBr}K>>WIS%<7u&+sZbRgq z+1#OiI=UZ4E5%$?h79aepgU?Ll24s3#{%e#$5LxR1IztS8!<2g>=FM3s)$3c=W)V} zRHpEmCl1Z}bE1_ywHdYw+uP9Rlmcs9BW^(7`_0BSC}_w6bo9bus+m2mK5^Z1@I*x@ zHzA7^6=kVUMfIEojkRDvuRdsNl3ZS`HJ5}HS1R9wSP&S_0r|!2UhjS%%Vq({s4ct= z!Wx7WDN|_U4v`qXJA$LnxiTX8W!e!+{7L{hL4_FP9~TxqDRC14vsX+uSPLA995Nu};{WfsEk0V2cy^=P3{#Q>{tMAxEaWH zb7fd3dEQ1Q$jMgd(9-zwoG9nOBtd;mb^u9QvZ!xtg}j8ZwHd=6#eMW5%IqC9r?xxU z%;c@WN4*o;OVClP{uX19N!B-5so_S3UqhE|lEP>-HVd)y#=>F{v#F(C5VJ>CjdFqV zVLJ@u)brZkfChZdhHK5#Hy7D~6qh~IRjjm&%E=ytscnIXo%4~GEh?C({Es8NLr|ZW z0*9{BX@i5S3QIQ{93SG7aJ52$kYtoC5%*XL7~xQW!(kPw zJ6Q6UR#B$3F6s;z4C5g;J#s&0MiNm}Dw(jmLpe;NN`azq4IU+8bY-#M$m9s^SYzYX zNX9M?Q%t#XlHrWK1x3=o~nTSYYzp*x&gR>++85a!AXw%bEBJD!b6{cdNo%j-evlb|BQbk=|Js1PhVTF!DlOJ zmjB|hp<}4p90QLbVf2qN!gBmE&mZNLVD&&Pc22%r7;l86;@?P^59flT1yaHl zU|KWcCSm==#jT>HJ6UaR=;jqN2Tn$*+-RgjigS#NrS{B)I_ggMl*-}6&s{mnrP&p4 ziKwl7-{Hpw$MFj!WeSZb+`figvR^~rLWdsj- z$N4i8QQqAWU8v$5PuXw+4_%!*d=jGVhDS(KANw+exPC&7Owty)URbf(Z+voa)PLMA zMCc&QjE={uBhaNPjw_4>*Aj9m3P+XmwaxE~0y%I>DH~8(I!O^+l}_B2=f#B2O!>js zSI`wxFY-2UI%U&C3R#QKYqksLO#IiLzHo5SqP4@&Z}VwRVE)_$Hxrb??|p6yezxU1RO+WRyPQn-g3OyreUqu#g6d-#mDvW#3Y(v7JyL+# z`$Dkr;jKE1Nb2-XV5lRlm%8{kDz(ZBm5&j&VDDnkKf3z&$wm!y*RyAuWg0TRh(cSG)MDX))Q(P2a`E@$R7;dkEQ70u#LD-Ctb>F3v~)~ z`4pA<7U`!I3a4*J$VgV*T8eZw6Z>a)c_r^!r*gObD`|ZBg@GIwqf52U}aAu|e9jA!;`W z4}C(`y;&6GU*Y*M6rW+th_z!Hy5*fhdjqqc9QarS#A*F>$b%;6b# znbSQyXpEZeIv$*t(yfbO4>hlIr054jeokcU*)M7JUu&#yvW0;3k3*cYs!$KZdE$t% zO)8msvajivbSZaXmaW=9FVB9hww`ztaNsy7bNA3pUe=V$UHRA6ZR0inT^q>KA2u@+ zeW0#u+0+zR3$?yC8PZrT{X|DIcS}=t6aNBF4S(ysMe7!pxZh2f-y2G`2m15AcUj`7 z7r=C^rSxCQ^e+r|1X`92AnOMCH8WkFrm9voO!?eJPAwUZH%?7{ZY)%{%KWt!(5PN3 zJ?L)bCX{tD2+_>d;lXfz!B#~Inn{^!+o+MoiFhdkp;?(-K))L}_JqFOwvP^Lq+6~x zwVbySHXJXT>Z7n=$6J_nx=GYUkbf30qj>9aqm8_*Pc59_q1=N^<|9=4=+cOo-%3#Q zFJ8ulxz(x4;;~s5EK*WbO>MhY43gb$+VY#+M}(OQ5BCNOrxYo`(C=*fiPHf&w%Z!+ z&T)`yFbr;MP+Uh>;VWxnG=fnT(|Y@=*KnCi(~b6nT|54MS$m-h<(4W? zW3-uTWL!{0oTAFEt6yd;#C3Dej8=iUb5_a{OdBxil9tI$@DvZxUm`o*qiUiA)e{ zxdq0%aG$YGinl&>{K*`Aq}xzQ+^113Vaw72nU6O&%`JroAwwJ*V{pZxdkwYj&tt5+tSAoPW#7SFp?&&W zyqmqx@~TYM9tF=}IKZ^JYvs$)W~03}ld7<&nf{jA0!7Jtl-i(2s9Ma$a)p+r9mB!1 zS%)~1iI}X{sA(9SaeJ`hVw#w0MubDh-P6eRrG8@i?%MUxo+`zqhB~7n&d46?C(>mLNLhbowIKv3lGKLC&z}KPxlTuPQudkv19a}s# zbmJw$t7wG?gnf7pjaxWoZ-c1`tE@{jFz)Y#i}Am=KsORTn#4^{Bo;G{_ z&?pjFLUE@*8^mnso$Q9v^e^K=bm>>Ve}g=myYC`9mdqK3ng+jejh@gAX)-WT^=13$ zRAx)1klyvbm$xj^D;D2kvU<3?cpiCc;&6isyWAO+^fIVm7Wk|wPV7Cn-E4nIo~<>| z^xC+V#$f(A3@hfydeczTi5bVu!4GZFIaB|9^D=>kKO?<@cYFf=8r-VO4Z2g2Yw$<{2BH1pCeR&V! zpoqWen0198PVu^W(aX$4y^!>?_v@8n*Q@HH7UN|jgq*8r9EOFX*k^X`FLel-U-ZG$ zD0VmLFQ{iZ*2?|}foNRET`qvESEEVmYTKJ1kAgCPj`W#mzQ+4Ybm}K+9Eig?DQ*(# zYo;P%h8swOV|z0c_@OyNY*}vBH*?r~Xj1Mcd)Ca{8<^^AafV7QDd;}x^*3mab=|;O zIo>IQr>_b=8pG%LGZULwSmCmVa9^<567g(}OG z`_@W+))w_8q;$LR%l2BRQOtmnsQWxF^mc3q^ySy1e?}?A&1X;#P&ORfykC!UW3!1@ z8D!SY7ohZ~C_M{fhFhGm)yvMO56PA$lye(j{=Bng-&tKY`r!WgiVlg?VkxWZf4X2EDnQ%vEZC3TuJBHrHkgKTL^g23^MvEC3$T~=`t*vw>7SrU zE~yf$uGIE@+8kCJ94@rof>Gp4vgyqH*()>7bBuh)JnKWb>i_Km7&9Kz2Bku{=`J;R zS7qMa)392y-G{OK?^QGvy-uhG=~o2rw=4VH!jIf9^8V~;jDE~?Qdk_(_#vMoywx8^ z-NvmdPBg!Cno+5zwZ&{*8dUOvtb%6cgt8Q-24_d_LNm~3?zU)GU7prB zYtGWP z)TEHzt0WxY*3VXRosw|>MWt%#`5}4(L9blvV?_D6YcCa+o~z@hCcoxZv(PuGGB{z* zo5o887;f!0^dSM4b6cdkYMneTGLaSfXmFn=G0j$=In#h~x#*90f zK3g18#p68)_~Ga&xfz?`9n0s%ge)lw2)oP@b!Ymf9Yf-GWCAoQDI-0((;8g?j^eDf zV7;EO;<|}PnIk{1s)QAf^!46o-NH}UW>Nw;x_iz_0pz72d(ZV7ukLTHUeq5d2!>NK zkxajhVXApdL)D{*p2CBAQuXl4!afd@bZ%{}&C)wG>j4vxN3j8w+GLsbLqvvOCYT%7 zgjzJ?T>!;Hrwsy^H$k#94m*j!VCQq|HSz%oHInq&24_WD!{;|4cWBL)^Q^)@0ne9O~#y4S?Kj zvfuaQ44Zm}8-n^HV^1t%!wUS)$dR89;e(853z)2uew$|pp0sb}@W-TEK6keFKv0xK zVFLIDLqYC#*_u5b2)V|MwtPaWAQUcq<#D){FOv3&dS5TBZ#NRtJdCuctN9-K?P>67 z=zZ0VBJ!2!+PytzFI)A*QZK{m<-|*(Rq{q2b^^wBdd(|S+)7JD8MB`@=CJHSH}YEC z!~+PnM#BF-hSman+o|#QWaKsOzw=hB!iC<*iR#w^r|)vgR&2QhjJUmh`SsPbN<+}= zsmCYx^^@GReGkUm&H&*jYAsQIpSNSt*sD`PSwH{yRn%9En706Oc3LztR7ub1dG+m9 zQ~6>1aZ52g85-#S>Y)7*sE?g-Pa|ad`=`jmN?#g*$3hJ1INpQM+y<%sID$gG)?A|L z_6-xsPwk4STlR~%7p)wC?78itZja478$5SU94@cj^V0UUA9TW6MhhUdyIK!QNFSkQ zD1mTPO`Mm+*b?w*S8Qi(1d(J|M+?LEHeI?l?si!klsl_8h< z>_NkTo;3WY4H8w&H%!CqLF3OIjmX_fGpKbm%`a5{kDod1l;Isy9F|``_0Gl#u=p+~ zQL^}G9fk7jY$m}|IT;zI*}@#Oz`8Qi+2%9&Bml6H~4@5H?(%*IF7hyYx^JoVh1T1Xyr;k}KL)8bF~O&kBY=d4A`p#D4| zIbq-yz{~`A&c6cl)A5ZEyu@6^goe& zONTbg(vJZ}NPXpPVa_AUvE4GB&N6ICr6?49n(_M3@i7}Pbfa+uDk($6?dIUyyq7kA zQJYRk_6{OsQm_33kY|T#>H7Vq-VJ$l-HL=MyXPXeW`no>VDh2P{Sc==L0ZJ_FA9!Q zx?b8jUiV$CGDM4}ahH>PORZLgu<);P*jJ*gop|DIR(%2eYzZ^qKB5jTT+$^$_Ahzw zLyfpn&HeX@c)v5(0sFOYd2aa|CEzPe)QLcNk_+30E~IVuF)k2sFU8&Z(fI9nN#C&R zIcz&UrNPI-zbk*D8}%D{ALyfAU$A3_D_W$J^{ff{t3E*ZqB-c@I}{>rmJDZ)edFN1 zNEH!c7wK`tIvcr_m%)PE41aE*?M$tsgON0e}|>rIA;&46ScB{E(m3 z&U)J?IvgNqz&bNA91c(PP#JOCM_0VRVApviu*b%faWJ-c9BN$!(U0?B<#1>SZEFX^ z^0=+$QJ`A|&Y8*VE)FZMaG(iRZqh?gI?Z1NpqAJ6@&ZVR6$H#)iHs0mJZ%t{rIw~)+8Oqj~ z28-qmT=)iXXYa=91q&eW7aW@4#5%!tBU1?p;TY^~zh`j(UA8&Auq2{6?}{?g*MfBWPwo zoq$FX#X?+{gd9F?6ua^pmg?^NX?o2V@*fvRyo}}nJbz-Txght_>Y2b-_*B)JsrC zCHa9r-sR4QvSDcp!x1$sU~_HN*%|7V$M^UOW(lS}`?{+GGr0NnYNbahI-}?Gb!ZhQ zYa)OP5tw_`yvfh&R;>a4+shk(tTwxet@B^Dxg;U30gT~!Gi!yfdzx6p{&*Q*Bs&$s7mz*k3jO)Nrw z=C4#DcDG$Cl+B+*X6>NM!&sF+k7NZTi-gmc^yfCP_hV8|qfyVd3&bBWg~!zTqt*qk zNN2k$zh?W8b_pw*=<+;Fw*vQo3E5Xvbb$X}3GM`MJibTtK($4#bX9O9T6%70D1DmzVOAlT{L3`d2_#+D`!i_TbLtw@^0F=J_A2#IIH(x8{j0Oz`igg=jm@}jIY3rk zvO+s>^1LgyOh`ixeDci^~@e``$2zPZA@Aq$!(evb?5-akk2EP zDt??iNgTM~Bb?y`g4Z-Rjy;+eaQx9F)PspnAJL<`8_F4Qd+ejzZ2yG=$c6zEQQ_vu zKR>8ne?N&aGlW_cR~8qc5ULt`7I6Hj5BBL*9u4>oToFEU++k`Fd+S%Bn=D-&Xx-O! z@8Ug`zc7Dzo^C37u3wC?NwtHFJGIJ`)^NqvTfKrrs7`Gstw3Rze^BZCD;Pi=o{-Jc z{?>o@`x&2rW~Sunl#h)HwMo#PpSfXu>39B&L3r5EqyHkiP|nk;o%E5#pRN_oOyOPY ztM+N(`&n1bFUGZNq*;z7(9@L5g32K+e-HX^de1w^cyJx`{O!oGPdPF6lCH!8C@nDT z(*pJ(n`PcAP@mWdl3I~40?hbAaioIpd>IBfKYoRDmnv#eTVuZnFnO;&L^tVG*s04p zWEO08{1naGh8Ez%OfL29pblNJ^gf798D?%&hzRbzatT0wwo%Ue-lqCf^VM)=DZL-l zEOj5PcHVgT5_Cf)T#0wOZCu8`dGYdNN4esHQ03XZ)iaRHl;rVuHrlZYzn@^1O@-Ak zgH0fq-KjUv1VHsp-;>y`MVxqSO4nxiiLnORy zpbkptl@aW(v)@Ib$&Dj;{**xWM$1W{qoVD1-Np3*WM?H|Vhm!s9bp-lEL5`1x>Dnd zQ~1Odp1Ue@KSSQ?(YR%NX9@`OkQ+z{IJ7lU(}ymKRI4Fj*ZB_BuPcc^PqsHmv&F<=sE2!%v?y ze;@p;|J*j4XOt;?p|o_JpIO7LyZPSv{wI*B_|7Xrtww8{aoVAHH#gq}xyM!>a?(Yi z#*mcWS(u|s*7*(^QZ-p>8iWNcB=kv>At?n+$jT68q|#v{uc+K_IJj<~vX=E|Sq2i) z50BA+tUn`bDi%d!WLt&d#~TVD2%_)8pg~4h{90a^d$Yb_ir-6x^S_m-_*ewgOymJl zPVbrWD@`;}gi4!J7i&L3E<%Q|#j615HuB8vI)>?25Oz|Sf|WMUiaIk=c@g&hf0tM!&eA}NiQC;1fFx7nEv3eDPE)J) zu=<=F%t6<5L%U}H+8ojf+OMcVrAKL7#*v++fAuxB{PdNQxhLD|0CDqHgT-J#T-c$P zCz{E$@h7zR|CViT4+{Hv?NsXLjS%C;kEG(`sZSG&rG@PN23etWt}_EVzjAyx=stXh z4K~YHEN0LDau(81%=h0od7Xv)OdvOTz`muO*V4Scd{={1)evOPRSWgN8pg3af?iB( z<-8d+-Rh7J-`9X5U)*X~!kXiO(fajPzoG+l~Bnlc`R6 zc|#!&o9!k6^2NZrDB7)w78%^DGor@sht%{-x+aMpLxD`tYPh>88o$oRlr0WdB%)D9 zNfOG3B>_xGpRqF1mtEmw^F;2vVFs>j_U26nRQ+!Tvj741)7Jo-l<}_pI)vh(pTCk{ zaq$+P6cpY^pSo83g&@wSlsMQ33Sd50O05PHZm+dmgtv!N>2yNkKCRRs%^Lc zhbHN&UG=-&`G=s*Z~KSEcpKH8@AEf77OmsZ>JGPOsKMQdHBu=Fr#u|}0*6Ydn%ob8 zeBGbo!dIT5iS^^=A-aX>rrE9swIW+Q7Xb1VVH@k-0KW_saza8NYFbLs)^uF22>KNP zC>!h9(4!w;zn;4?LR<`0=jlz9U)p4c)-Z&PGRNn&$cWgt2}3!%6+vUaP_7lV&P>P~ z2ku#3y#}X+jj7kWR^N2fzz*LBFldKJr9kC#-^uVrtnHc0LhGXmRYOo@ojQL21MIic zyH?*(8czuuJ8L!Dg>Eb?`UTQhPcs9ZqX*D10uJmOR+L?>i2%W>BTXH~@&Rpg{Mc)= z>zI9f2y^G=a1Nb(OFT8-bek9A=i8RYrbxHf=}SUBPo`xQZH*c4A;O;RA)LCV6LyBq zkA2;&aiGlv>Psi$w$4BZLra#{W6o^k_Lw&bDJ?6|gSj8a2h4hHQ};FkSk!AqG(&V# zo6`@c)4Yl*|A)Z1v*G69w_H)F@+f|ceEP^_%oRJ^p+aporb`oWv(ox`i6orZa_0t;Gh`P2mY5GAeoBR=}_>U*_F<>efY8z)aR3 zhj%|(A_@RCe$n~cz+gQA3NGsG$<*H%@ z+GOACX`^)3uV40a3wZKWXLWjdV@C)e%c~Ujos4k9LDjraK;ZQFc*wg4Op_|x&Y{^$ zHW`^8brY?zGF{W^2l=jU>r8-duBB4rma~wJlW7%XgJyjJ{z_rNE5T5vQ>96(53EbI zu~xeiq8lTO>*<;n3B}Jt%P$j*bPCg~kd19uM1&P3Onzd#i<@5p_+>e}dyjs;@A~CR zf_hoh_NpnUd6ylKl?$z~pg6aZt76~4#ymN%pTFNMgM_b{>?8Ngl~HP(wrc%j_SQl~oE6j^{*#%UO`+{woH}(|`>mKl zHs2({WRTMeaCdn3uXeECh9okj)`%53HCWBl{rQ zW`0PUG%~)B&PlLq`exF4^&`TZI^S6^*lv< zJ))4oE%#X4NJJ7AZ?N`HhGmdTlAy$jdPq`uMS|X2u|n^yFLcLAhc>eSf5TeGjq}Lx z(>ia{%sIU6o7ktAj!qBkOv^m!j&y%3rX7V+1eI8OZnWL8OdL^2*I+$xZF&2}(NDLD zeX;@~Usb}fOP^g)UJ+8k9XFPhFOYZ|?wLDR8INK%LhngxyWSIG-r%{lW5aSxC|hto zIz>U1WoYP#^1DO#)}*j*$D~_|hhvNQ?SdOD6w>FI>l4i1{?a`L$cP8bO96^<>I%Jf z*WLUS*Uar9i5jsBoe>NI!C!d(DNQ9&4f~75U_2O?zqAJAMS|ZuMsCi{i@Dz@xBwBQvNNYw)eaQvWUX? zje-V=9ovtIhI}K&zw)N)()%91E%lUYa__0;u8ASvfCMyr&N4{Sd(OA}$^b6ejewD)mvN`3$ zLC8kU%Y&QPN9+GfUoT|(Epn_{VCa9g@UC~}OaJ*>Ef6TGX)4r3F{4Tzu5C1+`*7{Q zU{SNL^CnKkVyXo*!S}==b^SYjo0jm^4{85dvix@BGUKPK;$g=F=|N3I7>R!IeB5X{cOy7xF z^~jdr8vRO=O)4LmokT^N2LGGKhrF?%kS_kg)%fif%mT-)q{$0gVh1M)bSih<`7-@> z=1U3C3)5DRJs$9!Diqmx0}{^5XHH5Q2^z=;Zw2S~S-AtzzXa=Jh-&d-+U6y^#FNBw!v{4L z60*tEv=jZqeSWV#@R-%8tDN~zd+p)|KT>AeSU7=c<;v1a{?_c{ zWgqz=cQ!Ne&qr=qK){T?7m<6oeUdzPybPAi8uTs;wh_LSa_CQgvy>4lk{gB>Kdkki zWo}AkR$}4i`JmD`-7YcH^eVEs2)E$W$)?DZ1yvz8&+MRn9Co)ximFl+ZN58}ndoS+ zg`U~S4&)tTP7lCl8!&C3MpI@eXSPUgN-Ekv;#UNl7&>7ZDZQP`ESwjLbQf4Sr5F`n z3mU7&bl(>`VH6Fgn|lAqXR6vHNs1Oq2y3U@dz zmw8$H;kZ+{R*PkjoUqyOyvhAb(f$wGZr;114IAX&;qvBRQ@2wDU)sbFlp+=*ro(%9 z(pKK4HQPm=I0eQt*(a2|mL~Mc&2WB(gx>UMvY3_oN>%qGHQ2GNf9SXWZ5K!t16xla z<%v8wZ=Ujb3$Gow8+Zr%Jz}#=#*rH%IF2Wvx5Gb_x~Vo*-UP?CHLkZcnPaG71@qK2 zj`DK^F$s^vD*;!n-QBUD8^~&>-F(%F#c#BYu(sYxOby4*>kVbi--v|0kt{2wd3jg5fEkO9W1sN_M??jMq zh|TQB*5y;!?g`rGELnYa+763{zcnd-!K40#OF;$fld=Cb8`E}K`_PsfsfRR})NRt8 z`99=Mqf^fH5KcRm5*DN`Dct1%MfbOuiIIkUKa>if;NuU;{W1#K^s*n8%YU&@rB#Az)Bh2xg?Y$-097dr;3mZ~rdpsR=BLrQav7PD~<# zaYD}gw|YPu6s@zVPrN=I{`1%ABlu11Y%kI1#l`Qw;~ls$$?w8G&m&9>Si-9c8m5+1 zeM)qTzsgP8H6;$c71Jv9KWqD->V`V_U7y?(jS5ul9X&$-K)&T39|W!hy^_`bn)kv) zRPzK+2)4%S&@(=&6p_$&Cy8wR|Kj-}ojcjIkGTG!SzGQ+H!|NSSajjT)Tbf`C8^{dPG2H5Dt{Btn zo6-qAE{VH55f1T)x4$y8#X`t+CpqQS!LR?4Ywv21oiATXddH&pzCLX|joa#2(Xx~z z*!h;MV>*`Ke$Fn3v#77OSt9`sp(>rZWwydhKbJ{Gx**+KNEw_(E>GU3#p^i`Zb1*A zD*zAu4ma+UWAYV-p>z3Zz8_~51Q-2fA%>i4#R5K#v1}@-fQ3wQ(vPjXSrrv;XrB}9 zS8U2FMXcjrMi74gL`!%Gy$w-q$zT6x`JxL;-XJpMFu4zV+#>$JSa+>SZDfqVVJ8cD zD>qrNa6m*t!SP>nVCndn=aaWLkbvF$))BIW@B!)VDi&7q+Piv57iqIVD0>)o$RwM; zx!oV3MSjqv{*FbbC)r%0e!EkOL5X>6wh-3tjg)B0K?bfJ^OpAZk?_|BN13WmJL|3W zWW`vz@_r8@Pk#xX{duQ}sl+z{5je|H$Ezpgtwhv{51X}XDl8Kf$fhE>Cr6Hl;f04d zs@uMaDfBv$Bgp*WGidRIBH@((hLRik?Pg45?j-p?T*$OP2CW?&6u77sZw}Vv9){Q~ z^mJWiFqVF5G**9?b7i}C+ELc?!Om$v`Ki}LM}>3B+{%rcH+8VxKIDZH2Q{sc#hxol zO-(~QJHC*c?IX)Uv0Nn~pu=8-4I_|FeWLeC=ESX*YKV&Cllo&g1y5}5r@aV4mx z5)-~DlChC(rN7-+2H3d(>5H8vmc5a*l5iqxu$qQZp{vrBI&Rx`O-kIXl0@Q+t*nvu zPsK=wPllo*RXfa|R4ht=r9>O0=L;l%QE_8x1H{ibK0k|YoG~YFOXv9KFNsOrf{_(u4Z3!SAE|$q$a|h{_abYE89UrqxZF1=~rgy z+rQm!YF8+nR6e=GZFepDqWlNS<<3V3Y<84=Q=ip^I`gQt7hgZ0BeV{N6Lx&GV?xSe zoyc#YBlo^2dx|<|yfx8n=kb!T*SKUXEPT2|ks$Z4wn8uekjRcyPM>My>|gdswRctQ z7)$0TxiiXw0ntmIRX*%?Uas2fR!M7Fd+B{=yU%{vzGRo~^U$coU3c?^{p|1CQdq)r zc+~0t$KHEB!u3Us!ox&wLB!|@gDBB^lvIoo$wcpr-UUJQ=*$qKcZoWB528$n5xw_L zkcjBL-jm;R@B97<_sjKdj(pet>2YZhE=fNLLL%Yy(#@G?9>??a8yMX zR6}A=VBuS4%Pg&y^f+A!6_2X-pg@X5v=C zi=tk72c&W_oT*ZLSGAoqt64IEp4Limkp1$bENcVfR1f?W!|d$=4PCWktCj4>x}og}Jxx48wh-o0+W*pk?(YS$Cb z-}AT5O~|`^B!&xK)-wGVhk5xP-&3)ummowB2%#_dDoJ+VwBNVUn~$C-s7~vA)s9?= zWcbR)dQ0MHHLhoq3aRcD%i`c0TH`~CIOxDVfSuI~2yw4GGkfy8w^38@wY5I8g(jL8 zLh8%XK$G(PKr)dYUl1HX@RGA{g_+{;I9D5d#D0n0dm6T#_-;wx)KY;JV~|ekUyAez z=qJ>cY<+5rmjyF(xNd%}0+&A`GDhw_KK%s9H6}(kxlM!@HV=7XBVxJtSTwz-r$!qv z%pmf4RX3HBArJsb)tamM3oGb7#N?B&XsGZRB_biuw}~Csv2=GXUiGL(ayY4O@n>&Y zo`3`8W$^k8Yezx#zMbk4VYOSz($|pSa=9FpbCZ^!LfQo#ZUD&Py|W2EmWqB99KFA0 zAk2!t8kmn%^APzkfQuRN>vG?F_eU`Tv%Vi?ey*H_$btR9e@nThettSfVjC zR^0C5-fJep)C}iaYt3EQ;#C4}*b~Hq?D)c%3tj6f45VV02i*l{i;9d78&Pk*r4bq_ zf>ICAoPy03fACABbkQ<8d|jrppb241)F3Lb{Ko0_F=rEI4 z_~yx&r-C1>r~BD*B!nt39w77Zh*(@Ywi2oXcRj)%AT0RUvcAvWex1mTk8mO5#XJHB zaRhzo#uW$GkPt|T4bd_nP|*>t2A3}ZdpW5$_XP}N1n6F~Jsq{? zLOII&Jx`D3Qb&$pP=G} z5=-)P_Nvph)*)%lMAnolFKUT)2~qZ9Ew?TiPi4)rZD2>EL{gL~PY_H4JD~J)*P@&x z&NI7U7F31M%Zj%dM<2Cec2BLpLw>RCk=1QJ=#lTIb-nvZQw$N|nc}EFX}F}l^s3!I z5zXiP9aTjGLB?#rGI|+BD8_puxNk0iR!|4!r5c@1}vmjzcpTg_}W^Ys4MTq>+ z0BcK#jKcz!ym{F~_Fv?pN!N5j{$^~BKYvdvl`RA=$=*{fxWJ}mT*i%@uuNhyEn zHF!Dyi*$8T3t1Km$%cx@nqfmA!IxQz1u{*tPt~(5)Zk{dpvl+SVZ*nU3b8Xd0r5;h z(BMcz*a7)cx<5H@%6)7@$O-a9)pcPN?c_5?S!xr4PVg(kItmk>9-sij$|M%|CKAv| zj53G12-Z2?ZXq)atf9%5{YAp8xZh$i+8{tcc>22NajSB5qS9zpobDZKZQ%k|Ctm;z zHx4qDO>oYbx@o~;Dv@Z-B7o3@-)Da8QJA7LV}0a|Jq}?r<6P^d!Afg>(XkPqe7>X# z5cQFac7g8;tyL@UqC`QHV?PweP!B^UZ|jA~?dH9QfSMn#tq)lvReD-1G4t0Yt0c3# zUJXT>>eC^Khn z={tq;_~;XJUn{coRR{x~{0)F|*Kku{ZF&TIl(+a3ScUa@aV9Qs9CI$*pSCq_!?86d z1Qc>ND`ef<}hbw7ollK)pg&BU&JWqX&T$Kp42J zK7og(0UK>*ns5OM{Brhr#R2jqNKW#eO0g;DlHlV8)wXuu7Dz=w@3T&Tz>< zv4Z)Lz;a7f^YGdXr1&PtnGJ})xF!)?j-RNRvo(g5GWJ!xq+UE+00lDUY~eb{#o8be zHAC+%k!~vVo_RLVJUbf$Hwb={bFBwx0Bgmh9|Xw*kpoqwu$RQ&7?82KCW>7l6+e>^ z8Y{}1^V_?`Gb@P}OB18QD`6XNRm z3!+}5H=V_of+N63Pfb;Usf3mcu>))6Ct+A)HT5p!x0uyBFnj-PR*JUE5j4ZO%2`Wm z+`EJbuJwD;&QVD+8EkW=9xd!K+J~SuP5c765#(dsfFb|l1JwNrkPL#@g*8z}^mz*_ z(S)!zu(3cZ6nQS5LBDjy)02G{=m^=qsw^2^yS{RHm$;e_#l#3i2rDm!igsu!_+gqg z{?q}6hcnLe{565UbSI~_aqe(PYUKR*Pxkh#a&(#w z+mdzOk%929Z>vikXz7#Vg;AylfId@ zu^(?MW)j~z(ec7~Y&aLPTsIg}H@7~LihJi$!_+N_KXu39)LNynJX1n(W0y&U*U$XE zjC*BNzE`u;4i55+JbBhTflIRYBd(b$ITIcLb8=!-JikM{{wJ8)NcUt*$h90=L&d}8 z0weQAp^aeO4rR_YWvX@tURZJvccT^FqQragDnIjyOSi`wh~B!65@vcfiKj4??Gs_b zxm4@}ATiIs`Xq4m7639HIICPJyf08$5vS5qC8x5paFns;OJTMaIfmSh(A()^)GI&A zZ<_9p4!QV8}=$Gjt3$Bo_ok#4Zd!2JVv~ zp2_7J4${{9prKb4T17YV18n&8t7dw3tN|#!MQey&If^~wBF_e@^k>&Rj0gBbBnRx|7|(m5}h_0_;}oU_KX8*scm^-iRMqICU-W z#Oj4SjL+kL#B1faXm7N!P!zJdzys_MXS*}(X&8A-d|+IjA~kW65NGFkbACHXTW7Sv zi)m{70Vhvf8A%ECAGzMM7wzQsJS8(gn4CEB$0hl!(jq+v8PfrFkIX6aq61lw>J&rt z5S;=EK`{V|_I-X#`8o(&v-^q_R;*36R6FA1E5|gj>e887?TBa(Nt7z_NfxO$jDECw zm200c3>)d%j**V=U;Ht4Pl4x6a=hy?GJ(?veVLO;6%>gL1FRA=gYOl8E|HOaFN1=^ zQfrP*w@tFz&>+e}p^wGc9eu|^t=VtR-_gSE<1&_Ler(OKom>SrT`AaoLTnVl-Kq+I zNbzaktk>3LlEz9$Rj>BeUVWM^k%VNu;aAabQYR@@Fu0A_V1%&OHJvqQk*mPNH|gO_ zu?Y-|+}^oo>ZTkV33*MY63fg<@5M82o=$P-^nAXGY*TUuyszvTO^n;iK(wX}6L=Is zp7w_1Voe2ADL=oHf6Tj0@WAp3lZ!u{dW1m`=fAXN=h5!a3_z*qAk3`Bgr3&u#fsF= z>4$mwcn7$%*t(;mc%5>mQ*-TVXM4xi2XGwXb2L~M5+daaP)I7q6=Hjb=s(x2kYG|uk z8>u0|)zzTX;nYZ#q1!2BzQe!99zP>d-TGQQn2IIlKr?=(-wh8@4<*1>0luwnCT4as ztS_0dLr4huBC;Nq;&DmF(VL&m2^lRHk$}C}HSPQ6N-2>x?$yTgCYPHrcI>t)z9**x zmn^u{0$7A-o`W{v7hwl}*n6wmVWr9`OEeo9x#FmYp4u8hS@Y(AIPVRVS~2zad=US2 z4gKZXFbB5_@rY~-Usjs*Sone-8+!+~`d1JO#@^1=4r6Aj3A<693R67Wcj}Yz0yaL^ zj4~|QB+?S;Boi^CEl8!}uYz$~_ptBUG23PTH~_LWZGO_f*$yXCpJA9#maZo3M7VxV zTJD=}eQ6b}r({0T|NW+~gBTxEJ6$vxPar&t{@`3zAV24XFZ6~9v1d?(flm&B*h#oXp3DFX8rfp|%* zKlVzh;sGlTU701h1tp@Ci=&6J=_E{G9k3seHAq)YR~&_JpS)+#T}Vm9Ta48EI*2ca zd?+apb9OfPT~rmi_MB;VCnb0Zj4ME%I;YCWb$9`O$t#p@L4=p}!KYXiPR;VXy5%a@ z*m)@rEN)EZV$wHrl~lEc3nIalt0UFntcG{ocE3^uIBH33sh@T55i!JR9tgftP?_RS zA=xqq=7TmwuK-=9V~cU6ga*l8J}2@(QW8$Huuf5izj-9Rjy<_0gq=#j^y{brB~zZ%!QU;wKWLh zZt#-+G4pg5?JHrLU@zf)eUI|oo*DtZ7!RYPo|CRxXWHl_ETHQy;EWMJ^9diK8UQcIv*189YrCRcG%xZKc- zeOc-#Zy457Ov^!Op#w=2S^r|m+SGwkQkk#Dk_nr9*uXOp14dIg;PvGIMe!Hd7+k5$ zAA;t)Rf^eo`pDAXNB5?SzflEMFl6VI>c!ZI;>$k}XUHo(l{IqCb`{*_5g2ce3JJbi zX}**vLJIu@<%qAA3xGPq4gjELA1R_(?F<`-NM+Mak?$veK`r~UIVerL7!yS-gm&*5 zu_aU$?C5- zQU@f1o~Zuc*QRwblO-X3nfav+#p(L^Tre0%vQ#|VFykC zF)|{Ge)54Oj}BIlw^m2>a9liO`jM^NLzK9D^6C6^nU-(B_%Z^uh_mX8M{|!}p~oP9 z@ONI%hSh`<7H4I}zRvDq(h(r%Ly5cM3Y36ka@SXKCC4NEH4lPnp}h668ex(!E02}| zHRVb4!DpW|pgDk%znd!GwLBEd3gBmWe8~1{9sGwuPhjrlWrtd_kOT}Rb|>Y|o>^U( zE6+2h?E>vKJ280i1n_y|m;QsR(--i!7qO7fjzb3O?D*KbQP{xO71)KA~S}gZ!9?>=59Nclq+L@I3yj) zldFBJ;9ojw1^D;{mZh%}05+|kl{9ur-`mgK1B&e{i_}EL&J2knpXnODrY9FUB9&Ce z2NA79{#`$S?U*kTdsMSgZV!*Y#&7-{6}CImYWE+sGv?!j#3lg=^{C$GJKm=7(_oFB z535a>(HfMZ%J>uEHr*Xd3v?b=6#MSnEs?N*0qFP0NB&ZpBx=gA5QE!kb21Y% zt*v>;?@DvTL)Vt3Pwv*)OSM^iFAiQcAN$FMEU|q^?{=wYYJuKd3yA=a4lCx9OP576 z`h``xtHFa?-GVWZbv6p~(@iNpfdltjv%0$Rq2Ai~|>p6?P8Nro6*nbafYgr1hBQ$bFk~17bpPC)m6$E8-nea)3@h$fL${vjG$UpDCsc0zb3E_sUu!*f=u(J)+rZH@1jAqs9H4*|*gmkY2~>PZ~icW4MeWt>Xpq->iib##_0J{gb!o(mGwr8ZXR4Hvgd!696sK;xh*x)0EL-L z356aq4J-=lkLzYIRB}Uf-gu{-A_f$ISj4CZ0)o731;c+?laTF(cpuCW2R{g)&L(M- z$H@RguL%F#6Chw4DK^QWhga< zy(`K(Z;Y!l=1kVi9{E5)o>EKzfCH2zr1NH7{0W$Ri_1QRut|I#TgBkUH6$ar!_|!19 zz3W{wkJ_{@D)Si0Nv71iycwYMK`CJkaPxDBvhIv%^&|WsIbtv*n=ugzv%HErJaNAM zT2+k8iqegyXViM5m*iEGZVjkK9Ztii$ZWICDOQlr|4XXG8?Q1dP8oA*_1(H1@mOOO zND8G{A=5u>B{SyRAJ^3i45y)^1I1A#7}?uyrPF{Xa|-jegAQ1ib%c2MWCZY#JI43N*6&KfM4dfICKivl3p; zdBDSW4%*q>D_u`SJulYhb5Z)9Jg~!%424zsMxKVdX*I3mF)@|dS>(e}FEE|`s1n;! zX19z~!b2b|y2=@txtyg^^B^d9US-oWgeOE5g@3ywvk?XqVSYjI+qrO0;1kb-@%9Ri ziDfy(am~-$-B-hPEpf0YL_BG553OPz(p9@Ap|vikk=j^{8{7*4Fh|zndB*#9oJpHT z>=hG+;qNGwKKtTf%gWuwjL2Bxkt0CQ5m;pZ#8TsB>!i9a5vK5lIe2JN_B5dzn}Yr4`pZ{XHvU4oGEa`-{~v^hM|zk z3?o!2Ke}qt0t+7P&zz+@MPJlu45$13o1$WaEXx( zZ=b>mdXpH5=P96rOaSIzltp7*509@A%2@mn*)$IGL4pPtDMjTS*+#i=In^yz-L zz+qfT!aRM|^-Ia(g5C6#)gE=l0;vpA6In#ic+=U=U0@kNi-Z9YsB*3?u~KI&PLW;9 zl5c67G@yV*H{Hc6B5%FsP9;kv>rnJ=?%xk10h5Z&epBHzR;B_(hlJ~R75FDf*XJ_3 zghHKD`Omj+)nmT3c@hdmRbCpYbQEC0n%npL!P-PP2E77DiaEN~!NGPn!#lzK>*muC zuZF&W0J6$lVIGc|!0OJ>7`HlbERD9bUyxU?Z@yKXfKUtJ$><}mAN3iVoYsn^U&D7ZDodk*CTNl)Z6O@c4_c6(lCLtHWqTt0vAy|CbNzsX zedXK9R)eMfJl`y(&?T56Ii+TkOtd!3om{Wc^eG(sW$gOrdHz~Tg!l=~^^Z6YrSHs2 zSwP#7N%5}vw%MzmsDOmcecs%U@L{l5SkJ|0CkIU$xj>-iP8@DIVXpltx!;%6TB}nc-?VBO~1Pu~S|l-`oz5l}A)6!|{2~$9xi1&ln*<$8DXK zwQy@98mopGo1;xHKTt-LK`+l&ZIIPi&apMVJ!3_2E$wi?k_&--{XCr;x!&TByp`$1 z{hGAQ{sMk!s#67kv?{L3oc7-3*@leQoo|ICv|4&hq65=|SV(-lRQ0N5K1pZ1o7cck z5;E5MJrHqpT=d#<#!0v0hpapZoP}XOKOEmCy1UV~F3K$y*DOlo-1& z__I2vqCTKWrbJRxg7*6oI^xSdJK;u`&c@_w;DUFjf5u+kOuM&7efkuVY7wt`W5`KH zY!K}(kx|+FPP*N=>ltxPAklh*(*mIY;WbEYR|2j`&ga*q>N7H=5X6Gi(V8M}P*`z$ z$FU^L(VJI4;`6v7BH%haq~xJc8_ufiFDE@U{da^{bWV56S^ptJQXa!LOCI)?D==HHRMJzF4_dONu)ZM8Y)TvUj;rS@wvK5ong*NJQgQ76yZmo4Jsrtu&s-~DmajBuje;77cLHxU@z5GFDa)7+cT6DAG&{kfARIT<%BgNhUFPj zt*0&VLBNACjA9=7bh1!Kwu+1`f=wB6;k32{l*m`JsHTNm*94bMhiz_d z?KGa8lE|Szc`C1%Ey?U`0!mIFgN!1`JN7;r^^z>M_6}jK%)#fSb?@3gCHG|A7w$r3 zLsAo*wbVCqQ;zy^ zvh1Zd!r#B)9WuS;vmE=YxZ)YSd@UELsnsNDUS?#knJTV;OA+pG$n$;lsr}xrGY32@ zgV+#34(C;T<83us(jeJX?R;-nl(DUF2f8HFPMXo`&!0L%4uHf;hxnD`zd!YeI(kH; z+pnw@iejIbF;Gm@8~n~JSuY~O(*}Ou_*YaO%OKS=LEqVpEkr(5>HJG=jPEJJWjVDO z@@8w)kkYtch4Gyv}u(P0^(451#ZOs zDTRTnf#3XPLx%~sRHV8rT)}^KH0Ksx*45Zy21be|eUNWR9#XHS1~j@_j#wj*bgL-U zo>)r~?tpim9U7g}v^`0Vfs?x*MR+JWf*9zGFAHd_q=D@a!ju;XMxPzpv^fQit<0}T zwr~|~f&pl0z1?%im$zD@XX}e3ByQa;&@6exR7lgrZH{Vj&`cdm_p-L-ceH@Z&*?2& zEsjSd+%LnfVx1I$%)C!5=%*mWB|Zel{vO*IYMaKkV5hNJUc_ae15y`gPX$r7G740_ z)3Hy)UA$Dp?Kob+jtJJ3N^2e>`QnWugtZ+IhH&{-Y@$KZ+bUox-l{&;+wEwSx9RwJ z4d{>n$)Qvz-YZG7q`(~0-Qxp$1Hr^Z_a8me9M>n4O5W!qviF#$EZtGcxl@OKQmC0d zQGjlGh!ZuMcJn~GwwHssIyNlJaas^`x4KE z#Ei-YqkWEdK}$(jdUF3mGJfwF^#jh_z($_2b`rn<53)rX_Uep2!t zpqr}p9w1&4URwa3;eUJCa>oh*X8{1p5=b#ZEar-9gt0Hi{0say9-8?_#VaMmtip?X zjefb+G9qYcePNj33VX}j5Nz^zq()C z@B_pIubcxRQ)og|GGFst|2=-41iO5Cvz-AkCR?|lHn7i1J)WIFE^#>9WXbPKXHz4D z&Qpc1zhYxjgG!|p^C(_7w3@&aZYpp6z(G>-P+KtH`D(sN(46p;LIC?$?9L)5^`Ae#M;?V&b976{ z28c;u8p;2xTlteEXFM>d7i<}78hfeI#i6+{JK-mnSbx{ldWmGjRw(<$RIf=-uHdUS zxlC)?dLAa5tthiwLgJz4K3SFYwRZ|*t02~rlDQ9Qk<=Ot>0^f4f7h+#=WP=1%MtgM zdP)FujIYnVFc;0jJeH5p1E1g$sanu7Y%9>MzB%m9O2r(aAi>+Tro3S~NsEFDUkR8y z%7xSZuC{XO+cRSvc$J*ibLQTnwDDFkmt6XG!z>WLYC#&>UY{<)g?V0>5VGR9YBW*) z*w{d-B8|~Ag7$Z+@H?~cE8|tSpjJf;gb?(`<(-?T4EHyPa1S8JNP(iq9zbgUg9 z&6ej#;5-;WqefS{#~Naf@~GXOzCR1lvRN56n;8L+Km;pkP`vVeXWuv|x2B z%%j|(S)jt`&0F~)uH?g$5VwG0OdU+>tKJ^@gK<<%divnVcLQqI#5maW$+Upe0vYNB zLk;sX{U99*$N`c?vL)WN!Why*t!W$G=Tqs&Vh$pzuYDth5ET;?)>KqFcWfOB_f!#@ zY6DJA#wXt)OgM(*1jmP1aHi8-YRG6vK7S**)Hoo#!+Trb^tL&-?GW&%e!tI|BZ?+_ zu?t=gQWM;L6^K)m9hn9Ky1;3gdUdn5{P+?Qik(Ya>+44cDXSg4id?|WDx$Og=KMV= z!`NHxXjz~?{%d>htT|x0aC`$0f>wgg81J}~t0p1rI^@|cYhcUpfbMbn>k{M>>?5s- z>6*uaxtinvjW11*(h^(F7I29peB;0F9jylMr4y@vN-X){?&W_bN<10zU=!nXg?1;Y zwUKUxtMg&*CV_z#_1k4HI?n8J3WZCMj&F);vKeyCBSgP?E3Eb)udyE>tLC4>)Co@)cMA90tI7t)i=~iI*$uVkAo_nF7X=fh z5Nz4)I(zGKPa-v6aVoCI=a{>3TcD)|W@HOyEw9EIA2ohTS-fBPytE8~;uH0!N2sOP z8p4k=#yk7ZEfH~fhO@Qps2?CUKg?L79MeZCKsVy*548tY%kQ} zF~cqWuDXfoI#ASq?BoE#Hq4PkR{&uH$sL`UPD6aC7wEUng`|S^I*>T_nUo%%0F7Jk zM0qh72V&W0G87x0K^`bgBO8KigBOndc;^hc?D~A>Ml8;nsje2ty`=+ygN^{1`3yIb z-c%^;>>c+SQ*fDL9NPg&X6LITW4Xqri9YwjTH}YlP;vV>;t~ z4WKJ#Qu`JT?$_Z>z00zy$xW~M`i&=JHhUA-Q#ticy=uK-a0;s{^XVs77)m_1gW?S7 z3ea=J#eA*fGz-RLj1wD_xoau>4Uv=NTw8Qm;wg6iR~=z2l=04wN;|LKd1LhXXYw;xPaBxGIm6N7IWK~E*Icr$269?Y4GU$WWz9JcI|#=z z;4qXEpu1e9`Sd|qibbSJ1^d&qZ1{ytT2>i%T**^RHQ}qwlgm~a%DPGSU_D<7dz(|P zCcQKjsg~BA>^9U*kpQ$Ywg!|t<3L-dA zR9Hs^>KR~>v!sGnUmF{4Ec|< zueeii&XVq2+KW0-LhByMbbAheD^@Q7aV&<#sa?mlF%7yl&zP{7ye-55m#okGqX&%+ zVnHdvi5_Ab^G03#KMF;wcLFnPKXDjEKasZhQ!Pl%LgXt|NkA&4_@wFS zuv-q_HquzrLxYB8#lPDLabz;Yzw%igh9D7wzO;0m2)AIRd`{VXcQbyx_YXG1Rw919cml8r_ zQe%A5h@MbWg5&d4DDyAxzI3&cdi11VN&-AXJ25DVD-=1WrA#Q%RB96~R%?`Rw?39c zpf~L`+A;ejkWlEFzJ{q`YI3DLO4&#%dYehU1E~ZjzrZK^=NI6qX(_V5o4lLWMyqca zZn$Q6$t=4TsCTYvP%6N2V5197FTB~lh_UY0`LQ0g_RR;dD0}YD{mjU>MvA(ApH}oz zZHR(BJdA97c~>hVJZfWJTjE}Qu6Q@~11Nr=M-G%z=_`~_N|{J5hq94I?YpbUr?Bzq zupS==+_W}=gyUhJ30G;Vt74&ucBHP7f=IDiD$SnTMd53p6r7JJAr&+>k!1IH8*Ne! z90Fd)ijCMxY2eQwYh4Jz@w5|t8%Pby<~yIlWDj@^1Y*5*+RQY81Hivel~R+-J&7-* zRoQ-n)pkB>my0pNc=WS>)eQ3*-3Sdlm4TYITz37^#OYDn*$A6m5L#Z$*BuM3Z{1&Nc9#zf0_e>!9rgxTr`yVsA|5XswTgpLxbDeH=+GiEpKHAaZnCcd`p z0Vc%+h8t`og#UJ};U3po>!J1%+a8#kI((sM4T&@#22>uS9$m_lBZ`eZEF7mNm3{ww z*_5S%3H5UR7pxZiU_OS^$0=gB(a}K-;GvtMVwYe`mIKZ`=}{c_BBTSK6O%*;I(g;S zab}g%DMhvcNB4&5PqioWbIA=n{X)1l) z?H;Fdt>8daxt!A;o1+Z)beJ-m_8c>&=aBEBb{jMQXP|G|zK?jD-Sf>H3a^4UC^aB~ zGQvFSxrCB__OG8|6>+*!3b0pv(Zm;Fs+nappZt#{3;GxsmfgN~qyP$EvH!Hc-}V;? zV__bCfnql0M>ARwyC`%s4OHL-qpDsJf=6k`dh!(u1rGD}&D;thZ@AjPS~6hMYGFX~ zonHb%lS>zw;$$F{YJW62V)4opIWR%MiGFnRvM-AI?f?VHrUhOldKg3ItW}g*f_xDF zdFzYZ+&|QxnQk>3tgmf8%*htU1oxi(^j}D4jLsvsA;@C)?l^nQ!JlA;WzLu{b(hur zrEsF2N zRx1|1bSi!&xdVC4#S%r-4R}btm1Fzcp>5+*CsB3}pk-nXqi6-?YY zNSOu#0y}6bigyGM_7PDK15aL)ps;FwG)$_0Fm zpu9}+?CR&X5a832*@h}oBf3!WI5Oo07x}JJQiNgO%<0!S3>l(ltgi+a4^vWXSY#9V zZ`}OzvzDI|5`d7$7Z$hy#d3`jd7%LXy)&if;15}i3{cLwXej2RO2VW{tafKOD>W?A zugd=gw41%VMk@H4iV-j?Q1d88(YS+i4V^Y>hb4mAOKA{ji?q)fa6EBD3QKsH-TEXw zt<~>fE1T-(^8I4Ij1Z8M%rAmw!}T>=R-w6+v2WdghF;>>F_C|j_Wz2p;=gA!+isz) z5%9OP*i`hi&ht_>J@(~Ux$-ag7fk%N0L#+208IMKx{&d#FiyEAhz|y^JAV3hr#MA? zFDVYg)aCw0RD)4{y%u!9F}L$y={L{RvdN_9SE_LBmQrrKwyJ}Cc_rN~^N@$W&`4#Oml)8~FZ9vYC*_NBn|ICTN6Mnsvs}5w2Ly$4=_z(3EKYmUg!3l!( zkEP3(tY!+@Yoo;^b}Ph)H8?evqbRYrTbb1O)bUlJs zhQldiFe-U@wSDE)j|p!cViy4wUpG>(Pt=iYni&9?3BlpZD(zM-{vkeodH={ZNoi{P zXUk?ca6h$sm!|h&-r{$>;{Veyl6rmQwy9a)`_u3$nEK95mFI?8lGa*FV8FPWXfG1^ z;X!WH=ZJ`+z$lxHX9-f!VJWDju;S`%XE8iT+vB*d;Z9rKjiAVjtNw;T*T9PLqJ|pivB}@G z-?P>k{OkplLf!fClRxY`@eZ-8B3@>D+mlq^C-vAMyZX8;e5{}TW{E}nT-_GN1Nj}T z+j7Aqxu;)0S6eVt(S zM?tkvwnL7IQaxE)O29#RZf z;#DBE`a!V$EOKK9c?E4r&vAPI1?xiq1(~Q@#kFJSEzn^;@ zk@@l9!-h#w*N>CVk>&BMAAi>4RfGmmGu2M?TD!)w@d1Q64|I>Ug)VA}sV$EKbP1yQ z?-D(Jr}kEdkWa|?c1ilz%|)W-yRCvEHf3d^ZhWZK|1jCUQHv_4BYQIi?PdW+m2~3m z+6Sb5(~)d@<&f!)4V?T&#~!Tx$Sut0^89p-9W*2w@TfEImu8hvsHeIy8(%y>srbGB z?~VW(B5Z|Y)=wO{wnej4x9_mW$i2z-&F()$Hie#jEAk4j0DH*Z`e-rU!u8BPURII6 z)H-=?G>hM zwTei5Qaz^3>3(9Ho@#BoHdo@c)&;ptb^_^o>Y zxnx$SJ9mL=jfdw#` zj_89Ic&~a5M7(y^7pV=b9mLPB-0L^4MdF^=vjDE^%LaJjkP=%YN)4m}vGO=jhuw`V z7i|^6Hk$AV*p&5)90(>~D+8iD?2n75T@#GJ&xw%yr^$lZhI zc(2BoL{B%Z%EX#1$N2{@WM4nA?_XzKeDV)vrxLo^h#b`Ei2k`wuI=Fec}iw&Q!ncz zJc3l;UV`bvbx_JouQM39jNZ8mI{0#*>NDtp(+e~eg;(Dv^FIj(R%a)^O~f;QaC(<4 zeCc?gU!k7d-q{ckV~wlTg6!r(h3fMO9*vYW@3zO9L;B8HopS!@f9BBC4|i0oD|rB7 zJVKFAsH~dCdGt&JjK6|)5*&l_@0b_s6Wqgb3w~u$=s~fxz1J_z3wI~@$qVG5@c5zrWN;Fh)y zYjX=q$r&F=UvDd;0q%e`I=X@m(gDa@6oShfCRS?zUK`5l-1oS>^w9)Dn8jANfUPpT6jF)GwU;79B_ZA!1Ss(1ZF)NuI0IR43Evrlpa$64}Z+*R#+&RG$AlcvO4}hx6stLL{=N$&j z{KW;dQ4@G+GY1H~#$5F;Qzg%?yo%kfp6@*_k>l(}{h6QKh#&w33cR;Mw*lm5JmL=i zGBTy_kh#CYGjJfdl6yQvppde1EpNyV+)MeG76`(}Ad;C-PkZ(7V4v z0y0(@oW23~mxG2`s7}9}Qdf;+0kPvCI^>OF=+6ul6ss%0kgdBK=ExLR|XfW?v`2+X?TuZk& z{BIx|J8%IgaFwK=s@otJyvBPz#iDBJ-xJ^kLD8wr^+g)s8eQP;&&4R(#{U&|NrQyPqQW68sX!L_qgT`;BO$C zlHPb(ZY_=V)KVIHkeF%}KwzN6Bk67*tGyE^I=X+zL88(R$4NIRT3YY3|M%Of8ir=G z>gZgrgR!!6{qIN6ZWB(ucnt8h-l%`%0N=?ypH(w*@XxUM?}e1Ihw+#EClAE}1iC|P zKsnN_!@y0BLoW=tye-j2@foQQ*y%Ll3n_8)p8D>YfxtU%uTR z?B%}MSu_9Vt$|iN5~H=j@ytmUH_!{{^rP+->Ki7fmv5gHe>l2?N^+jPM^|u4^L2~- zFPgqPkm~>Y|AkPbl08!DTG`>+BN|?I$PU@#+IwFOm6>b1W*Lz!WZx3Ha*eVH<#tJA zudLtG`}6z$?Ksc#JmYcB^Sqw-d5S@O0YQuRP(>ww7H1L1Id+xI2O*-G>)?rFQ@BsZ z`%ml9zB_yl9^hbw%ePh^{IW5fzPyoaGkQV3u7Q^yFKA8eIr*0`+K@#8;+95&R! zN=d{{I29p#6;GkhR2uP9|74ZCN=7VOmjld|R{G}P2_iwK=A9$kR7R^ zj6VibKp?2~)mSfT>7|QWd$X~Kve3@Vn}nYGB@^yt%qPGAg@HdCZfn%J?ENL*;JV88 zSqj$CS&u`^=&aNJheQK@(F7rNZ-iuHMll5`M&VKCT=JlQb1I!|nzD~8ufkoS1U2#$x>u_b1ry{_YhSuUyxR*>cS#kdA@GF){2$EH zEb;_%tdq-7T$kl}_LdQ%a7_SpTaF)Gp?N>{P#+_4noJeGGKLJ{3p(swDDZM?l^q1PCv52Vd@W-FG8;c3IbC`}e6(XI7&d8X-Nrl2>!+(bY@V@i{*rhebKtAhH~s+D(OCaz*uHu~ z51HBRAUt%3NAi{_Nx8gx5x{f(Jq%@B7~mWXk+uZM&-u(J$h9<%GB>A`AIwmPpxBHD zNe8zvwV0{o(zu)EF<)FUQ(2`uD{VS%)qmz%VWYUgAj@oGS`xS~ll>p6Q$2i)TMufJ z#~;JZW*Sq8E}dH$CCJ46+ZaJUD`Q0C_Y$|>k&=HF%3i&HyHnulRAN{O&CipyR30<) z$O2_1WhHKxS+e3@d&*43@6<~jK%Exb!-hFdD} zd{T=D-iWTXJG;cFW@I@j#1yl_XPRpF&G7cG4TJL3*1-}~c~A*WYo*40^fc<+vG8#= zQ5zQVH9O&A?VpoNBpSB5cDD+5)tLBAuxMfq956|kDXF`olck8`Ogrl0|I&C-y$qgD zBH(sQhS|dwc|7>(uOwa=uFzipVE8uucdi?#K!dtCA0&apK*!bz*g-pf)}`csWhyM% z6!By_^xww#@CgzDg4yj@BCPWt&#*cfd>eeENO0>j-r0I=Sk7RLj8|W8MZBF~2tKsL zP|N+7?tQ=C!W?6XrG^~qaUr6lQF;5b&VLY<@OpOa5sQEzhI0-~`3BkZeuD1$+gOi> zL2jkPZvY0rB>vW6d%BcKB)~@jpGG%}F(`rX6Uru=X9%ml-Fm!95|IM-4=81gJWM*! z#&F#A4YNui@>icoH{iekD`l>^r+N7>W@I$)&*|Q1dbP0zBk93e6x0*7t{5x37ITMC zA-qCyv@B8-uZgjcc=YFJ9zMq)3qeCQ_k@TLIx$^+y$2EC5K2o3;FX;yvFSx+xoq_Q zqO&@Fo5Y!BSN$2xD;^>7$RhD6e+cCsaGms*=A$oSgMZ1J z=aSl!u+QGxD(*ZD)%u`HwJ&t#zX^pg3A}sX5&Jyjig+Cz`b%|wn2m3t%^3}a6A*yK zf209b<&|*lWXRL%y!L&Q@G#6y!S%&H2ZJX?mT!}?vVoN>Ujk2j&Xt))jB^gB_~5}a zt_60p`S~8~E-0C+bSDea8|S@$e$>mPG4uf@fG5a|v2AvG8}>Hp*qP}pL0$(#ODjTd zmeRd@cIZ3H1dC`6SMJUIJcYLFN32w=(bW_GY%bKDl`u^wUSbQM!;H7MYt(%Nj?lEY zqaTLxA81^!gL7)9O=!W?iu;K{Zlt!)p?5Je-g4Gg5@7FpnQjj^x(qoUlS3-TOX)65 z>v_twGl?!USN6et0mkXnM$sEHnMClBgCeR<^1kv0Z-le24JHl@3{d&vmg=JWN=mKw zMZfMkL{E-8RFuId+>q}4<%(&M#t*_szw3GF9R^!5u8Y%$UYPG-@PUe^o8{d6tSGPP zRaNMHmR;q0by)&ZtmhxTLwTG^ zY}a?YTCa;~@0nuEFy@o(bB;_5*1`PYXeu5>>^)nU11re)YOZH+&$nHUJ?e!v$6o8# zm5k{{It@J~B_%Bp-{G>xMDQ%^E$x+>HI?aP5G$CbMhxBYxJ9j!yPRZaPYUm7_XFvD zUBCEL*TH`2Iym z_%Lx795r+-Q|*>7Y)l&5rdF9*PFv%bw8Ge3E2ilBI$eU?b^STrE`k4c3k=yz4-Aen zM^z*7M?Wd@R@%}VMJ270?F5U-yCzcMwj2^oUq7=r1o74SYv7l~cCuw_edwBfh;$Yvf`D6qu~E-}_|%93Laj)3b;ua#!9dW3)?$127(#ylO`~`wr_#kpcx{~4A=GVO3=kIs^L^*X+Kn8NsuLV!k zDxIjt*DCjI=QHQ_mU0P__Y`_dSZjEt{S$8;HijYiSHG)*gb_j(yk##|m+L6`lr#sL znbN)!f?u^h>`fr#qgl{fp*6ERne7aGn_X}9J=Fdb$6SDrOE`qGcCDL)rJ%z?TpGgk z8?Kf}LQKA3L5DQHp54h1>^#%D`e~Ek%>!-3O*~vR)}Cpj08E@K8Bq{TY;(%auz^O%|2OO|?r5Bnw`- zL>HPcZdf*8O3l~UN|N}=545w?nm>E%um!>Kf-!hy5KfiSJG*OArbMyO%!_QChRX(I z8Q|%Ze{R#$0vZr&ZxJQm;{J1n^vzYosFdD?z%(baV7DNO8S``Wg1$Xp5mv&hs9nc< z8%Yvk5ac4_y6->-14w`d2-@W-a3va}yx{ z^*h!yA&ABn&wJK>nEB_-c>$!t3_|$~x7osz@}z?*oWzf0&=)%cb;!RaNza*MP<(f+ zDg0q?uOAW)W8Lki3slGI%%OLe0}yhpU}^WcchaaWr6*f`Jm*D->OMl0WlZTyKHF>RXkTl=>~hK z^Uh~pM*=KJ3C#C9ITc0jN8HA=0Jkd3``Ebc8981X2{*MkP!Xunxh z?_F6_eJcbvYQIUK=Yu5Y5@^C|!)y2>*;<6~6Ca;G`hw8{%Bjt?7!NHA%$MTTcUv<3 zIjyXg1r3yWFz-ekvWb(|uFlgj(#A6gsyIrSqQbF&ZrXC8&5AiJ6STV+;BdnY>~hPa&BDrr*$XB4}Z-BFpe`LtMaf#Ax zYA7~Rlxdd~?uZF`b;{wlvq0^a-S3r-(TsT#)5)Z0Lh*B!t{x7WsZnXfIGhZLDQ)vh z1}Up2;~*uIszA>N7`NLr17|ALeL6|sI%#cxrDpDIy20lduR*bCakSdg$41FGbf18U z%I+%}GPxg(&J|+^5^EpFWSU5aExh=RPe545tM@r#o04`WdArHe%gd9d46B~6dMA>< zn|=t$L!a$kkBwUyZpobyQiWKpq**d?mqj(KRdlr*j|hU8B>{c3(WEHhq(?b^=abm{ zxw4?k5n2GB+L-xFA2_YIA@LozXwELEQO2B}(Y;|+0F~FNWeOuNU$IH|$u0LfAK|#} zXaZSlEgJ=rA5$cfQ%vyVZ&T)~c`ym6xC(33c9I;USxG}VCI5ua`y5YS%+&!$!Unh} z>`B-IOfz8p<6csKf_`p|j2YnV=b9;qY&UBWD^{|YI|_^5h4LT#m9$6v^v>qTxl4Zy$<+cKF%qo^ z(kYuah9DoqLi$94Eyan|;2drK1RCI?E?(+p5WOg%*YTECDPe?nTn)&*j+Y$0Gp7OM z33QFy-xgaVQ)veA7zeKW{Y42HS*tU^I@fo#{^hBYnfs0iW%Sh9zm=KNRH$J2-HVckJ@p> zN&S49C@Y7*7NxM=T7s;mhRj@c5UvLPzdp-%(lA!Z;MZ0A3)5aOST8B^6UtGRJeI5( zE4TI~poKaK8eLMmuzuc4Y35*&2kDK6jAtN;NlC&s%JPbh)0D02)N2oh%aCu!9~xaE zXky$dQ_26>q5U*RyZ<5|Zp=IbL?1Sqg+1Ww%bodXgn7()sOd*!$-l9btX^fyG8OKX z>zfu$CdrWPN~WYSYTBq};7zORfyJH@uth^?^QzsE@Ks8haU^~j@btLAhcnrSfhAUW zEuDtWs7+%7c*bvsLIc7PdVx2N84}4U!>R^qe^($TpEFpa!URD;?J$caSbVG_dg-43vv4?=J)xbgh#%ZcGjzCl&5KWeogG+e%O& zKcZ4S@{v-CH#S{_35f8|6Nqs6s-@E>SnBVW{uhAlpB)Qo-(gzY0+SA1^ z8wl%7=MKqiV__W@Zpha>J3&Qz>t6v7AjfcF{GA+L=jSO_`Sv7|5%TgiOMaJT!fRxdUI6#q0QYQOW26Vdl7I0R%zQ3r>5{5PD6ydwi{*STh_r9Ch|of5V*o!gBuD0ioV*u``@Nl)ow z_e#h3FYwQ|KJ6M8#NPzM2x>E9cNjz&H+m8BqcTSuS5KkC$CU6B$t#IFc7x3;{f6M zRjYbWZcUXwq;Q(Nu=4o@=-)2^I<3FPMHA($c~2A#{u;3MvjBrM3J@5v#&=w$`G2gq zPRI0L08IO(+0$p+?*XPWqMIl=fcv(;Y9vQB<|2PSHABMQK5-$d>xQ}35EM+BrQ+R5 zb7(=Totjuq0gm=of9ox^wL%saxbL%Q7MGy=gpk$#Tc@GJtwJi^-Mi13`0X|j4J^UP zRxp>&c1|9kVA%lW*3h2)KsHVQl+6WO#R&j+NAY3;`I^sxrK`TqV5u&Ql6_~kT;&c9HDW*7@ncpt=QGbymFi)SN2mpXKfcr!JPyCc% ze_Ko(NN%qs`UJ7Ye5NC~_my4~k*Xr<-bVS=M|xS1kNc|+)gCOOXP3rd|5C4m_QoCX zL1X`6Pvvz}terG4{=q{OSTBZ%<_U|X%1MhEPU_g{hVzVXZK z$Rg9hc$iGAm9GG7O!pHl#kl;iYW~BOM$s1+z(*D}R~@mUkY!QJyqCw+#_H0{w*yHWylVb-UB{*7W0~gIin7?4O)=`iJCaAn1+S~IHVLaS2V<~zEO6PFf zbz9kkb=<9|2wCBIFNaSuiA8v+IvMF!QrHGdG>gNpUoe-++^#$()G!tjlb%=~e5LHM z@azSJ1-S~hm59v9x6U&Ijg4#MYah#o9;6)nwh4^^O|vTyeCwtm;tSApFL6EjSY|FF z1~}M0FbW5zmqh^gS&@fOH~1cI5qsUu_A7hV7NMZhQ;85XS=lpEiz#yDun~h` zdRyza~u+bs1fa+7EJ$6ju_t1LeiYx*YEHm9+uhGzVgb8VRhY|J{d+@pE$rO*~tQfomc% z-CtLi0D6sg12UGt$&^TS2Q$Y(JgmxY5b39?`hYB3_kxVrBhZ4K}^oi+zB-BBy3Kh*KSl^v+CZbiQAA#JEiw|0MsZcHn zWfqT}eO>K=xjVu+7XzmP%XVoRM-y^kZ_`LG@UXI-pZBtsHV*WXDSw`F`oteiK)xzUIHz7?KoC~~ znq`f6AcD-*pM4hh;K5ZD?lfk0$D5dJ0ErWgYwk^en_XWLu~s7#9arbF&as%{=28Sx{%(s|yNNy_=sU!1J!A z7+TPXULJgA> zqAVqd)#f}w1)7xnv)hPKCiX8q4ru?rxVpi-^YvQZo&@oC17LRKVzr+E=JK%2k$?zO z-b?**$^Tn)?oHYuLw&X`U{OxdjJUMd%y(BdcD3=2Sdkp3uz#$E)NykZ0vw>RO@56 z0R%Tflom#wWsia9H8rj=+Z4^6)2>_5z7$P>tW_1ajd=Eq;^ykULK^Si0e}D~buSpH z76yP1dsT5Z$F1BKLsIaR2}t&3zdGTV%eq&Z{<~^^MB(TU z><;4mo$=)uf}fc)=lHi?bd3fR)`OWx6I25h|NhSe)h2)+mx>?NpkQSQR;Eby zGw+zQ61I_BO3&=h#S&?NZko61cs`A~oOo31ms(>G5oLRH2@#K<|E|h*;X3A_Cs*0u zsvEN?_SoQtkzT(cDJ@3!?z`1wJrgxQ)*`rVXH5XqMs~EzJZcXTCTNWPK z-a?9@ys@)lIrT1q2eh}e?k<~d><+_ypcZ;(1qk0_LYuqR08GrSE)CzI$SW%{Mp_Bj z$Sr=5N@yQxp@NzoS|+q_%au#!{Q64fQdPrdtswOPz{Drtp=&_0Yd|vT&Biec$0Xc$ z%+k_2SyTa7E*z;nEK&|f6MTn%NF^LMohHlU_-BD?3_nRg+NfCN*ZtG~ti8ur{SeQI}_9PJ2YKKoxcl0-aErS>NNkLj|bhVdIk=3q5)t6?71V4-vKQaZ`2gCvXnJef1=`}Knn*Y?)T4P$B zA#x$6r>LGO3bWGO;O5742osdqo=dNh&Fl(akl<9lTV zj2La>Q=@?YrN#w3qW70ifVmerW36t-oFMX>R5v5*Tp)0o27aV*AKSW&np(p%q|HqH# zZov^>nNBPro-EIY5i8iijOD{9XocdSbAV77MBmSwB}Oht7;HvhP<8Gb0P?>l91Z47 z!myDy75uew9zaubYu5tLgI(BoVH`oCn+HAy)SO`B^D7!5O&3&VBOzwa0H3=qGYw{^ z>OHZM=$*b-GUHGBg!luJ@%Ptb7N%*$7Vd;P_==eMdAPvM<5TZCUlmaxHP(B<-jjdq^pZhc zSvDJrQQ50PI_r9CWVI*sqd-reUfy8fQ|t$J$jF0&85Ev`KAGWeY(RGeyq_1oEB22#wx#hr1RWgs1SvGwKG_o zQE!gfSse39Sr3RNOIPAq>n17v8DK?5@;YcRn#Z(7|X`7*<-R z=JhVxIavt#XY{OI-`=(0-0!!vW8APrFUr4lxiR2ypk2M!wurj83;J%~0z?0osvHfM zg;RCf^5TwU@UJp$PiiNX8C3S-+=Gr5C4`bS1()I6c+HXP?Vhc0sw?BKW+j4)M*Dsi zJ!J+RZ&F=55`XSwXSFqZZQ&`z3hspO>Y+F)g-W0?9#dg`w9QD|YAf$2ZQ52TJO=RR z!%Lme8=%_S9|j<-c;u`p;ss2c>8Q1NHF#p9Ur2E;W}CCOUGG64G>An{K%OZHZ`t>@&&Yj0?+-*4t;QauZP{f zIw43b!5hH(<};g(!w10qfxQeZ%{6tbXs4;G-1ZDBzHW^4o z_k^FH8W*U-%RDv^{X#O+zphg9>r}HLm%IRknbVW2w4cyyfi<%i&|92IcFrHx+OFuM ze==epx^VD;>bC#>u^=dgS%4im{Ovb*VdTZjo}xVFPG#EOS-_JGP*Ek9j5abAZ-N$? zGH|zmusBBsyZQ5@AvLt#k`(-h*ZL~=Icl{2KikrvtnkuwzaU>q!{Ac`kZcFS9W(*aOP2q@R**}H)cLyARX z3R0a8)+QdnLE_-HtNQ3J0c3|_{F|VbZ~`=78%;3yR?EiPbyQ+Rk+P0Q2zJs8$aFfq zzY7$Q4ToO=$`b7)mHZw*dv?x>CL!RFI@ul7YPRdN7*3eBXmG>Mf1izprU9-Ydc14 zzJ7yMErEEdR0g|&E6@bZ z8*6#kQ2|n8)Y0w+Vv>;ybQ-_h1|;C@SUDEzh52&(G{leeNC(iI{XOa6-VgCn-k?M$ zvO7A}8Eqlfm{wQL%BNJ&%Ujh!avk-91GQ}betd_SS-3VH!y*jAxX_dBrFyrE`_5Xj*!Bq+%J;t`A z;_I5&njGg0mnC><682Z%J_b=-7W@6`p!_#%YT#~S1E1_klkGbrRvKj*wUS%vA>rFU z!#7^Cn%=-0gmoe#f#dw<@JGb65t$=%*eII*eTWoS{t$y^bf*#U7Fn0nb_gKV38d+{u3d8lfS;L#Kt+xB}iR%$KNfNz6qR*MFJV$sqIV~x#b+F+~OmDo}xrcJd zQji@kejil`$`4O~P2bGLh+gK;&zZSRBD$qFV-M^xl|XZLVGG6VzHb)+U4dEOu=aEJg0o}z*=g;rWnPkFB0{KJ_@d>_@-#*f#$ zeO4@0gl%x&WW4u%pbKG+37n<{fCL`*<7_WqS8__pP6`zm=3~%k78vGLaQy_x@7$L$ zKsi{nG<8+KO$yZn)-?n{8(<%2D}C|uyDkjEu_z<_&mT++KVHg)iq)JYb|H*8Jp#NK z!53r~H`lM_9Rqx^Bz;L|CBl`n*(`J*bAC30Z@evkZ6=Kv0hSnXzw{%-^n0&G2u#SO zIFTNy4mK-oznjpitP(mvv!d&5r)Ha*LKMDA#TxSE?yhAtp|`GhRKXJ5E(f2$^ux}E z4~)*loa>s-r3;GaC;7QGyF2(urAR~@;!dP#Kmz4bZ-tp`0Iew4|$LhZx-GIv2a zR##CiZ;Qus?Q(eC&a(qF4Mo=r;vS^{=9gSJLkt$(UY&z{K?T(7&bt2I?H0A|9~fL{ zvwM}l2JXq75vO=%J_MM`0a?J8#^>j{`r_fXWV?;1E~{*pXFE)bdf0iXgn%5Q#T|I$D1gh^FjF{66*b!FJ!c~x;bop|;h#k;TT^sNat5Lg`6 zP$GH>8n_`TdJld6+QK61K)(mYUqdJ-*7U;7>Vuld<4ABN=7MS4!1qDyN@da=_1lbrA@U z3{%(T@Z$e7xv}+dKaiLY?OZiEOtNa|0)OQ)WEHMP$VCMy-;#dkLnkn1UJn&afEo6Wr znde;<`6Pn=TK%c;^JSPhpMx`jmHDX!cLc7FD@^FUMTiSxE4$)e^1%8mfPSS2jl^;w zOZ5Igj^qYDq49#~QTlqXH;9s^vhgxrEKM29;Pgdy@HVg@FSa?rM}-3j)|qkofPPG@ zd;a|;88m8657w_Wxud2{BB24@1wiLmV_I4xG!g#7r=9{)W*oScpD_O1^JXU*qOfcf0E+- zv21|a>eO_m?Xf0Wnmmow1$L4AkJbn|qvikO0S4i00@jzNO}{kIr_Zh*evV8j8<^qL zZZ^ib41hPRNar9vVh`+WIYPID6a} zXYSnf+55Pw9l~(A!KX!sFHUcqfYBT&cGy&b7{VuFz{mtL;o!5OAy5_!M^XwE42rcL#yKz*%4GNw5O_tUc~9${b_% z_IXSkFZOgNfcq_1gCt7-A_L_M(=e!RLyt_ z0eA=>FE%6~1b9-FdMox+7M+6f{QMsODhJr`tMnAYTMh>%r{?GNF_9c}`3QLNDV{X_ z;Nk!Y?%>_BqwzeqmzF2Xyj%1m72(HNT@%FAQAXZbhH9fX`Oj5EwU9f1htNn}R%D5* z0a}?te*QqAom|QOJrDdv;Oh94NBaGjgP&41YM(>_<{5yw;+>TSlZuC91r=pyEL4tO zkTxP4FvG5?2O_ElmxN81JiAECqO>IVS^k&c{3DUDXI=>dNFV^D|eo1 z*1AR55c96)stYnC-{7{_iN_eqBoQ9|U0z!$=D6Xk{)aq*vc6|8h>ODV>s~Pe7Q7DH zs*)XLk5DY*({|wD?-N7EU*-NIfyZjO=vfNOebvW@e)%P{By&bqmT$3P%u*(z{x5ZzOjyrGF zA_RJEDHZz>}g1`>x@{?@JHtn(z#bmdhSvLt@t=J;;hF=4eBw zuc2SlwSy6a8@5S=o-f$XJ(*9NK3o9rZ}5R~_`-k#M@JFWIMuGe8g^iFkt5wH74hqJ zW!}8kW0haOaG$pm*a1K>cBBw~6j8s+uo)YIUPtPZ8S90<+S|uOJu3e5QzYqOk)KxsIJTp|Vz8a~E7n|~|_1ff)?Fq&X z+o^uP#XvE+?7Uzou}^+Xu4J()`#M+XLyLfCjK^a{$xR>IFHZdDE8|cR9AYzKV7n=E z{xxvt4YiuXf882 zo;!&5Hj)rE|1H(ka1&#Q8K(KyCO@txSUuJ}m~%zRR2aq(dq}5gDJErK7%**-t(o$nh89V%R%|*g7?Nam$2Ux$E)Fra3 z=aO`F@)&>O2PuC9pA4%te;X*VlP7CGMY?C{M&iM7w=rP~qD^b_Rz6sby38SyAwN1$f)J%TQVlvovQOZP{2nHcfSKO;-<)|`%;v)L&W#wzS5H;@ ze|G^|Mo%O&Yd90_ONriekzccBHbDMlcD#bpA^F?^2> z6v~UkI}{>clo@v2jdV1a62^7NQUqT1Oz;3v%&Ucb8*h_DkUq)R#9IyQB&v5Ac4Nu7 zOq?M4abzo6oJp?!8Tvd$`>Gpo&scj&L+LP_1O4*8mp#Y>8lUZaz!%$EfG07-k&b>` zlNg@V28h>aGfD_~5C%LVW!cP&uYBBVaYeD?SJyCvQ= zQ1>w&$7WZ^`wyHb*~jn=MwlVJNyg2v;Ou;(t2=k&JN?drB0%3yfzMOFp()?BjNYy; zt-o0I3@O~2YZY$cP{jIo%#}9jE?Ts&BN&NsLFdIo<-uu+4+_C9=aUGf zNrY>YGbrGcz+qf6&I=Xn&>d?bN590|;eQ!Jll8M(eb)rftTS0xdjQD29o?jfzrn7- zT^}1&*8x1)8z`SZIkHe4hbOskoM|~1NLM88L=Dd5{6($Q_ zC1Uulk;yRtDqXJ;sGeI>=<;dQ#ES^47~6G5PkvMU*$6vz%%&vJLCjb%A$8#82_y-+ zJXFChU(NB5ta6yot&Iff09~BaV_vb^?;J%^CXp9Ixlk(WIT&MP!$voJ8`bI{O%1@T zQEZ&kgMdbcP_U3`;(I)M`2?56{GYaCwd%Mxl))!)Ad``OtM zR@){6d4X8Nzn03T!foy3d=zOM7hy~kvBzP!la#pZfqa5N;9s~)mD3M6Fx8lS5D=Q= zw0lFR%5_Ckb%t(gI7n!JCcyvbxR9sQDp6rmIdqA)9LNm?hDTxdC;5v(-+ph~;H@ zQ+U-zzddjDdQYyG9lZG~qKt4wlUetwd7UCgh)pi4n4#wyr|RIp$Y+r=WY5TI&%Qvw zH>0!CVn5Xw(R{lbO8DQ|&>|fbFWpX7`B2i-CXnj4k%eGuBW~D^qiJd;Xo}oIwN|50 z9b=eULaCI7cX(=+hFZJxhWhj*(E>BEcpK;VxhF9GcW8vNIIhuG!%?JKV?HFD%Tv*l zbJe_khsW=acTGy6QYdjP{d{OrmA5b`+odn_ugZtF{4mO+xYj+L0*cQlsf&S3ViJQp zM^|Uh&7Pf=GP!Cv3k;ZURu3iN?4Nkj5#?tnCLR^ zT3n-RATQ?_KcWI*-e1MA>Ngw2n8t@an{~%1el7w`b)5_qM39A^(p21CIP~K76mx&c z59_D2wBs6ak{#tL5z(9>&8(_0dCf%6%v<$%YXMKW)Pdw%{vl8C2A97p^-=q8+g^k9 zdBQ4ivLEE4%p8P@zTEie6?{DufClTBRb^Rv^csuz{C6s!BnF*Z?XP{ISmzN+-?v+U` z;`Q0>zPb25<;ypQ^vlbE-I+$Hmskgn*sUWUCJ)`PI;CW|Fxb+}3~?#bkC@>vgD8s6PFV`(+j#a?=raklB@&pesfv!$OS0ceS}RGAVjWollT^TwTx zvUv!U2ywx1UtTS8MhMhWi)1XI$W05WAVB{(&2=5*y>Si9l+ zD(@_FW|(x>B|H0@BOAhL-aE}0rFl`w&BYH!iF3<=YWyWXEHx$+&6yz&?Z8l5bPNO6 zOh>X$wFP^s;PtQX6MW^PBe-8Jzs~OVdwg&lhLf{R`^LXBKH@KLO}R8h>x+1#Bb z|5x_$!G^eX==?3Yw;TW5Kc)2OV^MURG%Xm1GCQumvo+sq6QDlww(|#x#jYP<4M)29 zAElAm*Cvm@UC?l;6311-4ZBMMHFA5>yb4`UakS{ypP5rWnOzDLAOD`HUnqmuo*TS% zm~|^v>$9e^sm(zf_Yvs#30AR~k_R)I*6irid|Hh@q}!ZGW9nYHrs+^Qyf@j5*RT-E zRp0hkg4KRg#Gg{S>rd-78GCAbMS(;S!83Vw?as5PWGz!4`uQJ=oj#!GbwAA`HKwa> zJiX~8INvV*K~+ql*vC6Lq@~|t zp!tt;#baUkvF5d|35$uK4@Ua(OKMH)YzZ&F-!px(gOHgHN)C9IT99>UOg!^b=LBxW z*kvvy^{6Rw@tFH@3-lRl{5#t|HSkvR^=J3r=PtRft9{HnS5J&`BO-O6=gxbXbl z__|S0Z_{mlzctgX8EKrhE5{3+zta{qBga`lVq*!iF*#vgea}wni;X+|cA{HvF!^uf z-NcEXpD{|c9yc}@zosPGy4eB`dpM0*-QWGj6sQ=&7kEk3(?p#hq5QKztYZ~+$ycfw zHj=h|CG%^J*;@Bm+ECp!&3|QFE(^oI0)DCfba}bWb_b>df!Gx&&=#X?nLo`Qx@@!o;z1aIZ(I|6V=kdh}i@1q!@@-?q#*^&id?a&;%h2f{8x8iGk=<*Aw z*eDcoX61J^nZm*hBJaMKWI)As+_L9*cp4KV^lCXON>{(bjV_DE;+Egyi>;*Zk9Nd7 z`8@Y&5~$^p#_bzKM+Z;3HpS;|mU!n#o?86K4yq-ICm43(+^3bGEoGeJ^?(1k$l~Lj zl-Z4?HRIL=KUOMbJAuixLuY60ti^{-e(&F!4?R3a;}u)tm8|8)dzKoYyO8o#^TM$6 z<02#Xt$u|@cidwiVA1%e#pM7bzjn2IT>fl+L6k|mL|TF@Uk*iV+3p`sP5*qcO{qTL zH|m6H<4^e6V4$~$BRrZnx=}94>t593<)3YRvKZaTTB3zYLYT&To|N+Bw=WGO8?ArC zC?!|{dwvYK);%r)GK1Oyay>MVuPRvE@{j8^%Xxikif1uHoVt`bXH#E(UlfA<8st{% zo^L$ll8X5q>exj5 zK5QGMmtQVL#ZRpnc1)-#?Yk$!zAWMU?+b;^67^81T9n{OQd9O*bd1SHdR&bU786yo zB#^{wxP{32&Ks#OPxxAOY|eFOvk0|sK2gPygm}l>6p)<>t+1z3V||aao6%kyzMl|q zihcV+#CP6}M(JH|%cM$pUUsB|Q=n}8^sxEa!YM69hzyIFV`BRoo8V?FH7q~APw|{(WM|;{Oe-VhIz?N92(wn& zJ%$5gkH(C4eX3gaJ@N(Cg<_qxzJySwvWn#&*{_V>%7)9+0o zyrs;QI`xNQPirDW^YG5h7Zd>8$Wiz!`f+iLftx^*2@K6s` z!^4}E?LdYoKSZaJJYT=Mb^(w?^90$NK3K)Kqh6=|O!+U37vgRedD+a{xseL@baHYc zr4MgN_A-!^$b?{3b=+f2zwha<|12YikgW4~kCp2Dl1gq2825csn6-Mx*_EOhr0bab zTYC5qI1PC2!k>+m&*@g!;MC{Iwlo}a(Uo@&SExqeFZ}|c3AEkpqvLDwnJifw|Mn-U zSnr9m^1{!#Iyp>MvAYvyCCZbwU)O~TxXf75IVVAr`mKDOqSumeq13io&k;_PEB2q7 z!X4Uz_bz+(h1%~c7GP>eAVo`#vJ?9m?~a5@~_yvY}%@%+zW}1G4%=w!sAP8puN@xnV*oe z@?W?I`_}OlvMB`-3ZCBuavmr|{7#UM5bf z)jDg^`tjekoO8W}$1)ia2@1^$x%D&8@_F87upS=7&?)))D*gDt+bkbl>Tq`qDD-07 z(FOi?lQ<65$g`HdMK+;!k^qhX0g{Zo_$~R?u+_@|;h`%kd<&(pSp)pED76 zqRwZ+VXtS^TE;RzA`ki6cgFnfXomn+UzVMq5y;}NNqLGUOCSAu8GqFu+f(2id^22blSxKzVyz@V*zj`k(j%N%lZLlIuU0E{ZQA*ESj?>r*!1lja~`I0486A1-H)9_^*GPm+J2ZZ+xf<{S&^_& z9M+s-3D)HLkt$56nYx{mxj|zR*@%d2vcmDnz@Dwo#YAm zi~h}u^l3R<=H#h=YhQKFM9N08u+Zb=au->pe30iI+ssvl5zD8{zo;YLeEI02?t5pI zmDvf232{((SLaJ=cWtYKoQ-CxvyZ?cvE&|&k6((N+e1dk((jchw7NufC(?I^fuA)& z#QTQ4QO}$sZsE4{AN-xQ=RWu?l!|{w`m&KPV6HYodqO-t0T-xPb z&1_fe7Q1UR--wJL^yEHO-|4eV2&C;vVd)-I0_jA!ZWQc`-MYv2jVhC!E6il;h0~b= zYQHp;^4|tX*UaAghlHwO*qJ=F-IUE!ikYD94NIK$yAE{8OWJ|9-5PKkSw(H zIMqghE%V*dc^G+>GXK2{sJwiS8||Z3)y%zV&jQdDQz%hmCN>Pfl~;xe>SY{(z6-~9 zOYl)6lV3?dX>51phS_>097Oa#I3Fl9y}*rrwsiKAqufC`~1x4#_ zRcV&hkWDt7wKf7H=_XdRnF+Vd)XQ7>i=a*KzG?T5JI%#|x9kUV0|Y|#6CJ7cK2(hjs`F<>(ImD3*H017 zomF{%vX2+ib)2UZpJ6VdzoIeBp~sZpaPfz0L+OZiRkrRlCn~vKj2+G<)EyAFR>Sk? z*KI>i7hg-h^YijTYnU+J3wGatag!J{(t&Tk1jm(mhcJ{izrs0?r@NgbH;fpM2a|U! zw(b|t$5i)u5}5vy?bOpnve49%fsgJVMDZz&SwqvcMx6G~H_23zN5F zqRGNq&kJ(>S$cSS#RuJvgc z`mrRfu>;eQtDFDHT(Hve1&nWJEhMaCE7LmUB3Tp&nk&7S1H#b+!qNY-bS}Q2*Y_;j zmATVCv^2Q!nc1ElC=s&V_rz_LzcC5=qJUaHdto%bQ$Yj;y>zI6n{3SkPg|=6E|}v-*?K z4Wn7erJ@P_zV$_nbu(fyA@Ok>g|pX50<=D)*yD!u>e)ME+jrL}i3dBId5HB-lIVi` z7_Eg`ZiUf-_i@`CFaIPuii-oVToV~he%PLm^n3KnN{5PuVX}_UxlA_0FZ#tk&4qt^ zPhZ0|^~#!zRcxu_4LylL1E{DPPsr8G_U|c#9|iZETnASSMZcL$k-V4o&jQ&@adOI9 zLCM?pq?||7ZrUSmTLr?k;_f~QTt2w3$9pmRs<=+XWEU3g`~V z?WH)?Dae+UoH$1w)SgS2tIrb3LPbh%Riyj zdHg<+Fbn5U+g^$>Jzc{DlzG6c#|4((J1%&ZysPl+jgWWw=|B3*As5CwFCe65PsGk@9*ZyMu5dpI=ZxNx;LoUFCP!N?IP_L2|Q(NV=myfrzJbNF~ig#Gs zk0kQ&2KghQqhp7M725onQ7kNCOe|sW1}CO7v3@^f3GwYPH&1eLnSsg|0cZD&k_?CW zRmrIRKG*;2evt-r6Q8=<{+oFI8#_id_VR@zLAO|734KR+8~0`J2FKb5m8y7#YtdIb zSgqLf9Rjxhscrx9xK*x8WnOXi?T_)tJ6H{sTH$7mmrhSxP_ip9g(dB>rQi4YVby+6*uZ=5GN5|oIdGC*9JKm6`nwy*RN2m~Iy^SN1UtNum+Lr5V58sN z4Hn7#?vMKD-cNwE3tM$j>QS^%R`>)9iCx|KDT;*n($};&W8se&6KCWxMq-sI?#V)C z-_{Lty-9&x6)l!TCA{%#UNjZ**iN8C%sI168!z&Xetak&{rwK}!JXz_N*9{mBiZB% zs5HE%47N2uv)a(AyLi6kf-DoIpHD`P=Q!a64lqmpXo8Nk?(LIT?ygwHFPW`7ZtyBL+-6Gw% z846YLUDK=yoI0sa}VLN+TRQs5F&42Iq*7kTFA%kvTEyh?qW~xJ{!7^BnkLzEeUR|L~9&p z0WV{oW9FYs`G4Xv>;%475 zW+eiVXKwgqky@`{hUVNVZvN}-rL#KCA<;Eg_Hp7b%9{jVO}kk;$Est?BkVS z^}J`@Ra=#%izRsz2@*qBZr{p-D;uH(#e%xa?lCP^JaBSRz91~Pp5usX|iAO$e=vps6kqHr!6#bUUyAI+4&d}AUV7EYyp>`Yw3_UH!mJ>$inf5|&=tOlJQ-Z6$q~pT#F504ayb1gn%JcRkiuNB= z^wLeQ|FQ>H^ZTk@ygu|8t@iHXUtF1N;2KWec9*CTj<v~!e)S-WT+dYc8b7#MNM7rFl>4d z@L+#P!Q|7ZvexMO6wgRMXbWX2A%0such<^r7k_}hc^vY?ADc3=vlOT)o#DPy^vjVw z7rOx@P0I15JhvoP_WEII$Dgz-HpN}ryXZmg( ze@L}ITMSVnkXoC`4tm&_? zWWm^faoh7B?g>Tia;%%v?}^Lk7H#`mv2E=xkG283D2#8ZW|`KPlQ98@_EigO#jN*& zT5!3o7fwy1|G`6S@wSjQ5`@?ecEI+WlFM(a5^H8OBPsLzn!M?wIW1cGGtz`jaG~Rx zjM&qkWEA$^`1SOc@Pf|GzO0PyhL=0;zdoyUS%E$vM7NxVE9RhtEnSHY0V9fAat49(prhI!TIM-WtPn8Y{7DU4?Jc-?fQBSif|Kz>C23qqilF@7c zdpkxv$=T)uONn7qVofDAiki$3vChlrs4SI_ej=tPogp6t_ZV3Vrk38-r7y|Dj?Y^1 zs1Oo4+nvE`h8Hq{60RLky+?HzAO)P4@72N*RwzmX7u#uvZAU7Q>TAvi1Z-PkQ`5GV zeMua2$7yKoFrO1#+wFG4t$|~w>y7a3J=kkS)`g__?6Jcmr~#=DBq+bV1e|K+9E5^4 zhD8Z3XcTbED(e5-TfonoPTyBv2qB3a`2noF%R?rlR3esp z@UTjBK^8eo5!0B$uZl>9dWo8HeOF^vni?O=3dq8%na-$CXH)PC{c-o7`HnECR|0Bd z9F?bwvUwI|=9Bt~PCx>aei%tpn(X+3{&LwqTt+Le@vO3gSFx{GDrk3l55Ik)HV$cz zx!q?ZD$yF!wtf7>r-yLfccGbthKvb&^<>gg5X}%~6Gw87>i`k@sW^dkY&yEbjBKstI|+bSRlRw0*7bLl zzp4JcNqW}%B|f$FeI1UI!P5dL^|1`37JM3((XDwyYPJg_skq0M^*SqN8!|5|{8(<7 z^)G}!v2l}y%O{>Q%=5!%^W&u;r>R#}*WSOhBML<&;!U1M3q{6%+=8LPLa=YY9O;@o9E-EBU8({p)?SM* z=+Dd-JUGn^a`Gjf{Qk32kK>&rh|C9k`GQ^S;*4V{bU2DdMJe}NU!H$zpGf$OHD#dO zXp!iNhFhRB>Sk&1Ti6!dckueH2UFMajUMfJZMCi2QF-vQ!+)EC%r?90ep_5(ujTQE z)It-^b<5=2RU5+vC4x(@SGtJRg0##ZS-8-!RWFuH^eFR;XJzlIZwdPOGbVi8I(_N7 z-ijuHz=|)j78AC<3S@rGu{#L`4y+{)I^5{ae9qLWq99&;RPKJd*t(rW0PA#E&l{i4 zn6nq74FwDi5n(ANFR{~}u~Yxvu#2Wt%dsZ9H$_PuL@#e!IIBQOJ{(Y(igeW(<*+X0 z|8Ouuf()mV=O9!1tAX(`UJ?Et?}a7k9iK<^oWMWi4B&cC_{CG4IPc*biRGnFZRsD;Jy=`X)#=KS-zN|%Z2?jvuyu&I*GzD`^UbA-=auz zu_8I9JD$5yU!2G6iw=_>I&y@diKS-)?vs!gd-&qiR^)qTVN z(nvx_RsGl`_}rRJm3PF4+0s(5#A;aWohdqDzTevrPc9qhvu8Yw!|7#W0M01dAi9_S z36Z_odlTR!QG(Sy_j~`QIpU_y?GMSn3Cv2-)*kGdNI4H_r4KFRg9@d!WP{Y`ZU%RF z>972!`Fx)%q9g<>_K`%CC#OnFwqz5sMMMs{Vz-gpxs?UZhvR^7mRE!h+^c#&S2+Ub z!K-WjW?5bh=ci@5w{@A99GPUk?winQVWj&P^JO--+B$mx59E`&?RLVq7(Y5W**9^p z9qc*S(}eR+zs_gO7JfYJw3o{9y?7bp6{>U+g)#2-rale0D^2IOuk{m9v(sgV_rCtV z%C#)7g?GLmAX;~0-dj$NOeyx4Jf5^PBsADFuWA$*#;enx275Uk#_?$o{bpt$RY#yu z@`}WQV#HsKb!!(NTHjaKnHzSY+c8rARDuybQB6NyY6*PxSP(nh3e!*6+t5q9!=d?{ zjp=ff@z$5pdd|BJ*l@7?no{4qQGmMQqvTwKt93nO*z`WLbdS?SFM-LBYb& zd(AgxWypPp;pO=l({-^EyOU@XoE~TmMMF{X&FZgzmsH# zQ*HL)_~=xN&S@d%2rk9qcHHnC!ezrQ8Z8?AVxktYLR^ahFFgg=0Rh8*XR>dVtUv9J zA^#%l+KQ3;ABTsX&Q85BZK<40fLj2SVf;?wc8=P9iLeVkbO~sl zUT+7ix&ijBO{WE0?#RW|*VoV6rX*dRJHtq~wIU4B002~Y_9b~RRX)u}VrnTC=}OeA z6+1EXO6JOshhTkoslUM%zPw%<+4p8aNR~decl9HR7`E1DS!mc5Z|OXUTjQ{jGc3j< z#WccO_|Uc)U<6Smb!O9fijevGnYM2GUoX+wvo^^?u^Lp%_l&nbl72Wjv;K?Tmgm@5yIHw;9^0bhY&I^ z50{LN6I>J4pKzP*>9z9^M0TUO8D-kG_IKUB``Zuz(NX;`z!n-tD~`sn2`2WD5L`_D z;gwUU<-ek-;-|Gs?Cx8gB*8L7&)DBZZ&_=CsC*<7iSfi?tQ%h4EBKsnNO8C>aVrLA zp3xy9%ojd7)_CL^V#b7w+xb^(VFwV$UXAr2Es>fr_pjPl~aF_cvX2N1|! z?&xLCunWMtz2mPI5(3x(Yx*YRf?X=&WGHm^I&19AMUuCpfe3wdF~H3v9}zHrG`Wh< z%o=>EYF%}%n?YvQ#+*pITT5`6W+9UFCS3D?9o~)*c_$e%(vQ+eR!D-WHddjb&dpz8 z_9=JOaOoTsf5QO79-HLoC(WCGrKBB8`!SzV^& zzh;b0ygP4uD)DG?2UJ0Yd}mL|4(8o3#);Q=VIdT4ICB&2^A5x*l_wq*}6d=7^}|EY?-fiT$_yIfe&A<4|X}6Sk`twYRx?Y=fSq zsk|D9RU>ZPnf55gDPZ*l?RrK{VU{Q_I(&RyvEN$KH-?dslrvS?f~iueqx6OL!6>`vePqKQig9a1ObOt17hhn=4<|E z2+8u#PT*AvZbDdnU3LQPZU*qMSXcFn8qzUwV{wry8>@5Pc#{%Rk|9g2WU;HHZI@bS z^GadOwPXCW(Y-Vv$(}3jwI}e?QtDb$3h;AV#b7$zcF5CyT?4GejWojAi*r#lujTo_ z{oQh!g*HTJ#ewCxt?$&zk?(2mjt4_z@9M!Tib;D-84jT-+PvnBi(CH?l0u4Vd*@10 zjA(_y0bFRk3>QlLtQGS{U-=oyZ|fR(@2aNaDD}69cuFN|w&F(hH=X%nS z6Mx+9MgP4j!-DsTt!E{RI8`r;o-K*KtrE3N_;1Z^8@MBh6O2|nn#t6yrD~$=G*kY) zD`cJD5&dXV`;%3s`gf?m8#Ro#H#guLdshAZvOSAie!CjQ_(^`AgHUb#>yGGid1uo6 z2snY73DylO$hf11gn9`Kf}Y7Biyy0sI2K-@XeY=+(>qo$DT5}vZgN+;!nB`I=T}E@ zcF9)Tmwz-Cq}vDYT~Zp6O;XUdPZPR(yuQqOhvK(Ju*0=mDq0yUJdlTa(&iS-ZI`Eg z%l|1uQg)+}u9W-9Y=yn90N)<){}TLOv?5Z@Nqy8|z=blFRxxQ%1py|@2+e1XgQSgz zFX)T=D+Gsjnmzu)a&YzuDKV@b}t|r6uEKe>?QL9=MSIPP*&$B*jSTI+6umHm(yOg9Pc9~H6ZKZ0CulfWG zPw7Jqx~Jy^7*AIS@lXF&#pq+nq~@WZBz_E$tOk_R^n!a;5-Ph4!@g|YR=>KtEvx$1 z?VRv}s8zh3R*_W`cIucpoZ&#^egbL*{%FWGcrk(Y-&mdkau(xi#F3NZw&0W@M!SZy zA~~CWc$(tR1S89LO(LN>-yGkAH+TGcFI~A@Mu;#DHm#JFjLR#TcS<}@uFTe$i05PL zIBuSdsc~Ihcp9R;Pe0yD#8>c>+Pl7LnD)B5-1qQd{$W6dafmhD&+Dzuf?$3pDaoJM zHzdB0iE|CJdx}4X-@+m#*^PZjvL*nY#f@P@e#X8!{X!)I8A||WOXF}4Pbk^O-lSj= z#NG97=%J%??XSnFqd#e{8LM)9aa|Hj|fyBP#rWVQrRKW8a;A7fASM5(;CTbUlw zV~214oSKx;r#?$J{gHubI-LC9Z8_5DV!e@>M%^Ekvqr2or&|%*Ouu$+bpCNN`=A`;6e&DE>xI!~ z#YUhFd+NF8e8+4OisTqv57lU17QU0@m5lqr!{N)>S4(i=-os03eTw5l??8i}v=n&V zE{Yn+1kyV;cpTijY6cz`HTz`lEc2FV6H5%F84Nrv#Cc?ASvYG)xX4Y;8$Gh4$Aa z(?6_*mVL0s?CagR=VM#`j*4btMSK>ErL>#F&t?#Z)O$W zIe0{x8ua?cZLeWTLLz0t5j{g>2EskJ6Di*uY7@BNn-Buh{IQ7+^0l22drJ@(Yh#Q9olz<@_tdkI$=Y}{{#anSNr zJG;;?F@@@;L!AXauvb6pZhmx2)g;r}Je!h;tz#uu_ZsgRr#k55du<$83GO!Z#@JnP zV@!zq?m=5-TTiN>yDj~qqA>#}(hIazRIwr04%kLmuo2OLC=R8k5-}ekOxKa^xd(?) zgRh96XYVV&_cxAZ?{>v?v>`D%oWitY{C6e5iuR`D9myU~8rFuy9K8Aw>4iO|yy-3a z9C#(WD4e;>Z&|p=HInay$xqPYT}W5*?G%i8AQ)Oq<;bmNzyp>9-`KH^lRHOM6VD5@o~kj=njzKR#Sjfl^y7Pa3xne?Eh_r#|torOBCTMbK+NWC>!_Bb0c*Wat>akB6A5uQ&#;! zpFH%&cCF{cSUAW~Ar8i9V^Iy(`30>GcYV+aJE$wlF3YpRt@8>jv2>p(3xN$(#zuf!i`Sv`e{gwqMt{(42Br%*SWf0msqU%IXq`PkZ|ke;nr%Yh zuzvAk0Zg)El_knsyEr!#m`w0AsMj)S5p!WMJqz49KWFX7@{;F>qkLOI*Jdi3?=NU{KI%Hvbky&YP=-P1H!D}lQ! z^6*aw(LRFkYsAU-n~EPVrCa}gkKOj?EHqNzKDjUl5(D>pLv(tW0_65ys@=Wj$w=%fHGwBk#zaf zUCZ^nsq1euTvC$PNFI{!ik4NPDDN#<^0fd^u2Cy$mHJlfHYFw;YEh+|-H&76Q|KUk zuOt)PP%LT=1J^l`nn*6n6AuEk<-QA*dhm*o$)&$5^;z8Z*IwZ3UIlDJ0Qj@b?*7cx z3z5`Oo0Nw!4vVgTRUv0Gy%_)}F;z3uhVYoKlhlIVA3*fiEbv|M`?%qS-LC(P#h(Ft z`Udf3e}hG-N>a=;OAXT@caAzmCLTqG3CFK#?uxM+Y~Xge`B2rUC#?;+#CcI zeQ%4fO3?H?1;Lkv!R1x}yhol->8>Tp-s(Iucl zDC=n5o`%G6(-u|7LJx5~jiCom7bEzmpR-QbRyF=)d<@Gik0Q>{;3|wq8Leni>BzAd zfe7xq@EYK+VaT)e?iO$|%sc}n3LeSH0tTXBh_ALlO%jEkbS_gvjzwh}mN&aoR?1N1 zffb_c$Ox;1>C9^)Vh=&|HG)0EFAY|f+(~SR?Z^v;gMhb=u*EnwHf`Dq!@b=)tuGZ@ z_>hd`YyP67KQr@wOo$u&qXFYIO6DzcNphcDvxZ2l(T*#E-p_f(O9+cZWR2SbH^!X8 zBvyOTKbPBVk03iz_fjVGGM+;HS*fL(QBD z0R{R3QcQ;*Qy11CXl=V0kg6qkhP&vI`pjACa9N}vUG2ECEBW>UI(!&aeCeDX+93cm z##do@HxYysbgFX^l*)st426s*;MVYb!MDATkS6O}45YkEOfYBr5ouc{m~1I-pz{7M z#D(u3aeytnlBx^SR;(trwNS>WBZN)YK@ThQ4DUO!?Tz7eexLY45kXMwV^<{_*=t$K z`Y8b^Lz1CEbjh*Nt%)ikbhsuJ{=7G>TmJn9{yUMmvu~Bd`9`^+sxe~~6zkGWHL$ul zb_SgFT()o`!EYCU(kk*N!KFT7%jdIUXP3-dNrheNs}$GOX3o`9FuWodfYxnO1O*x% z)0B12yWlER%&D%dyjm;h+Wq=Zr>fgJt}ei=+Sx!dkzLjQ`R3tULSr08)yXV3TMhYj z+#Ot`3wPKM&4O^)rTEuXyREs+D9P*IP}ENaUEBoQfiY@RCzDO%B&y*e1W2R`G$rNz$(k3XDBE5(g2c{;If(N z>`lAbsQfM+#|vV2MkVVGgnWKxu?j?c06}i|9Uzoda+o~IlUbngmC9Mk-dOuPd1hj| z?4=DyL6sBcfaxQWFFMLs+~c5)x~~)YIqc#fU5g>Rch#?kRf6fwm6shUdMWS=NyG?_ z=lq_p8S>CzO&$UUMbyTpLJuFbb7+ni1WmlrA5vdt+PF;L&g`8wgV;e`o$dkEV zx_)SN{LOkM>@2Tuir3WG`NKFKWl=pr+%}V)=^udJf>Ayzv^(SPfC+&rhGwsB*u{Rj z7D=;Qjh$ze2S1v@st{7*OFv1SIy>lI`f$+l*dLNSbPtpVurpH`V?-1biwf2_wlg|?eG((GVMaV=ZOtm-M>2_Roz)!CpGPFah)tesZ#D8NN_%L=~PkNbo;RLTc zpG@S-CyTmdtXr~%)}MSty6BlrhF0eQvFk}ZaGcX%_5mDN(<)%FjRiu))Og%hD~ZLj z6+HX-f8Pj*jDg*1{9dXha^f-`=*(y||D!nAZD@Gq7-ec!fVflkYWyn1RU4xH`G0Bj zuzl$HqlL)eZkQS7fK%yV_ETU*{tt2Mz;qss*@WvlU% z)PH$@8fS)mJ>dskm5m}&OqnBn!ht31PND26gJ<{m{smoM6Q9`-b-gF=wcHfyCIEiu zl{Oq!R&XA$Judu7oeklC7BqA4f-kQq7GP|4N|4=0Lv>7N2{JDf!^4h}LsI88V=N2)20UpWw7O_D2cY>?jo#U8f)~B~8=(CZ^ZIGUY+A5tQTjXNPS|C-xsE3JGp()q4;F{2$fCPx zK;%5K!^=PE+0P1N-W`LSLi6yyKk-GgRe3zy zo zB1;hgAguIPm=+Z`utzU&lGu|6-8uFyg02zx4J8)K&vv1Q+ek`ZSS}3oY;Pvwn^ZM6 zy&$n3v6{^5L-RZNP0*=pz6no4f$2}q9OLCm*`awMLU5`(Y4jwaj*^x5wb9^k&6uUn)=m$QeHvaA-;*C9~$2ywr+%HgwA?UeO!`b@oW8isupA1w0$5>dtz@#J?wT)dY2U zrzItvF`MS&;_*|8#GX+IxL8y*UTATN?SS92O=gazHNEb*p0#+R$(%g2K9@;u$Ataj zV*?xTN}3>hxMKnSrUE%pIP9>QUF4CwOHOMUc3sXaUENv+tQ7?RBZJ4#fZtS_X<*Ah z)V;t25*a8?>yyAIf^YHS3AOgp`s}+`D+N1er4-n{3w96S0(Ui^t!1xEZ z?_}=2uP;X!Y!v1rTGj^W)%@?OK6}(uu_Y1%$6V3e8C;o7K716BgeK{@csCBCbZ5)4HARv8(Yzqf?3U={$K0= zj`K9{H`FX^nUsB1;}nSbFf(9ashKiax4b1q_kUY|vI18F=MQd%$kFgOknWp%hdE|m z`3*wT>a=&m6np@@O$I(X&O&g5>yw4^thSIEjzq$b z^6CI{dc}bXn`j?6m;k`?4fTgpwIL}BBwwDvDmHn(Rnzs!G=!R=T=3?@;n2Ey*HlgW z9WetYK5Em4Bbh1!wftZ*!xL@D#K;?y@5nnL_&rDkbj`~EYz9RxpYF*1!d>60G*mcc z6~=M@|9(3JdkO}f%#+)|XA~kXN`26~TCls0U|Hw@B zFGBllelG9_XE=8*U)E~*xa8xUZ7S8K1f_RvPCq(+d&V8wIxyLRYDH06D~^vy2zPe? z`R>FDsEA}S@8s1{F>@Pv)X@jzW!aK#NzYJT7{?PU$YgHul-JT z#Ac$446e>j^EiT7Yu;ip*9=B#;J(Iv-5G2qB`Yht<+;5w)k*lU#dFLH0eMi)fQvB0 zeDLw*FXx>m;c6x|lzTS|v2p zUY^49o!|o;htCv8$GRqs?N&Pdu(EnxOuL=GLu}#+TZu*GvH=`8a7-oQERDugPt(u4 zeKC;qqU_$dZ7-j$8~0xcs1z(R(T;efxKMrZUv1!4=HUGQHXs0p7m}skpFBwq_L(RR zh`Uxd)ZymEa`sYCKh(Q6JHf`(^H(eTBh|6DTe;z< zC+^-SW`8eQSt^6%JSizNF6TslwSjpIe89oltJ4c)@V;w(6tKp$89+pP^y-vVf79gq z>uVq4iC%PSPM@{GXW;_%RH{KsDRL$Nup?f*(GqxC9z!8H4O*rzDNa_xnl{`qX~mis zhZ$pr%vq+=V5$zcI6fs-e|s_b1mKsCMB_^5K)&RX@T5|377RH(DlD3|R{iDhaZQ|m zjBl5HVVX1}DoyKcn>O7PYdX55j5wAp{V+}OY@<4USm7(o)v|iSonJtrD#qc+J#rPN zP9N7`>AsSE2v}sUr>*-Btd)rRvt9F8$3(r*ox!pur!jz4B{k{> zucHMIKfSM_yU8w@C<(y11P(D@P)Lf2h}ayw5{h!BJGfTG$OQh9E`UiC*7Wf|sq2@Z zpsr)m?C*LQamM@NzK&nE3;a7kTt8NJPX_cW%-bx8v=^p^Qh0?n^wRIh-MfytMKvGbxRC&9{S}q| zx7*RO`f;m$_1zK{&`V#FDAVV&i~-vlnJ`}-NB$34gT`3ti?RRuR{t}`ye?C5oGqzP zAlrOqUG=$l^#Xj28t*6d6*IN6a^9e#%E_;&iDP$fX)7VgdsnDM%}^(T3mss^sK~A9 zuP7|wo>I6uC8)I~`5ymW1>N}0By9sv7u**j2)dTv&h5tjoBA7g;e#Ax=i~Z@`jrzs z0uZ(VX!WQ;r4XD)D5$9LUC=<9$7C(g3{a`DV?bICEcshZoNAAi{E1zaE}#G&SAAi& zPM^I8KlkFIGsdY`P6*J(+-143+;Mz?*}4+@PwySk_`HpE!o9C(@wOji01Lg5roJ~b z48#=y`nHDe7jkpMR=H5OQ0wfGIb`)3)p z`W#D$v8{E&-TCu<#{>opU4MCKavH}`=y^?US1#ZU#1C~Y&(es-Jt4NPr*9Zu)NCf& zvmYoovw1|y`|*`^Z3tLFVM{2#RtJ8luPs~UL9r*a(D@*xXJ+uC->dPl$=M0#FO^_O z7;sqW=ozz9S%Y=OqB#sx;ixiUFoF*8Wcn1`gw%dL%x(5>fNLoFQAkW$;$~={y7}_3 z0_`RM0~j~sHhv$ElE-Sp>`FQE-3%}fFcx_oNR#piv8sj803 z7+9`rycfd)W;C?fyXu2A^&Gp*$(_nF<~4Ogd(>=i-1XcJyZW6zI%{ywv9dr zxWQQ$)TO>;H85)U=1~+K0Z(&o)U?1m$u-i$TZed)wpb*zdc*uTfYje`Y6B#~&c6{V z%(h|kYESt&Us|6l?$N(LtAeh?*45v7$e>k?k9NAjk<+jx;L3Gs%YLr{LXek_)?qR< zrgBwiB3tu^27ta^^nfXdWQ}3}(@v#qpoBeNWIiXr^=VT{VYpkw;xRC?Apt8Oc4Zg*FBX1v~Zs zve@Ax|2rXZOd!V+Xt0hPHRw)|Y^=+KSr`>Yn^{4OCzkgCsFk%m$T|8GxTlUiTQe{* zlM?`Xl^M3;)U;Y9;2cSov1rr{;-;%D#jWrbKt%8+Vw3tEo%)W#pPpJiiW0QZmNN$? zOfGFHqRIui$l3fd;&Y96Nr;VFhYOutcG3!1ax{5&W-A;f-`W zYMBJCL3f&1F`#5I8F!YaSIz;?q8eOfA}y6>kV=#Hmv3^iuF@^MaA~ zh=Qt(m%31D+tB`-z^#syqh%I=uTfuT8$7OfBu%LXrvrI_8j*GNOhsX^9HzjN%k>qg z^Lp+VH$c76ayof4*!^-Locv}MU`1$tDJEy)T#C?Av^Sjj`PdfYmFik7+mMQ&w!SzDpdzSGOphIY?X3 z2>I4-1acp_JCj$N@DlA!_n}X&){x26Wdg|LCkQsgn^-*3>FDR0109u~ou+ZyyZ`pj zV04rHQ0L07_=4v@8|lv75>Wq#y|@0Vdh6amX+%P#8|m(D6%c7j>29`kN_$`<-5_j2 zl#uR_+|nQ$5UEXvfYKpdcX7`9y??^}>5g%RV;qt7SuxjKGoJZO726JmSNH|uE{o0- zsz3kQ9Mi&$2Xlw`f0$^NqyRd-m)6u>WSG3yCf%6`gu5#|0t9!#1Bv>EKp0Y*sUF(R zbk$tmZ%qOs0DUWZUxK3YOb!XDJ2`;mz%hD({oc6V8Mph(OAvZGqwE_O=0QrMm=I*6 z24;XB6k9yrwa8x3V#d@$>bt}5JL4C(M%;i#=xw&zdc#O{iCsJztHPYAm0J$vR7dAXUA-$ ziDo}`y`d70ra27a5}%m1RRQ+IHRvfd`2*brX+99qT?WRr*1+(_T-Z86)pHq+XY?6f zhhpHUReXWWPG%7!gC(k`_|xqYvO+qf!kSVLHu^$uQkv!LMf>MstG$)`64xJQkX)>k zBj;(pqhaS2I7JLY=v-%XnLdZvE1n*%ll5>gBO&b@t1=AF=oQ&u(^lFq5=gFIeg&A+ zS+Uyox~%U)WFPc?jy7WX302FmX2D!utlnk=E<)EXRhQjYeW-|F5hG4h7GQ%AlAA=%c!g+)c*y>0=sGfj zd&Pu}D@IBnsp)VR-7_BEh5U-x6?eaUM^G4Dy%9nLkM zVX}WNtZ=iy#Lp*bQ&zcWo`n1Q8sthn!za9i@8wdE%Bkfu;J7Nq6nL1k>z)Hn7n|kx zT9xTF;>*BVBe@CHPA*1}8hAXr)XP1N}d?l*&} zH~Pahb$qpt?7_54ZH@((%WTA0)r8L)D~SKojb@+Ij(&v!N0A9gAW3aR&Lv0)WCEwK z4iDzU2<(I$cpA@potJWS^{zD&-fieegmq($kX~3!(J%#tc z8w;85!4x$4zL7kI40>E%VQ>zp2I*YZd1k(S1G7E@+sdXy&qHswce0hATPIt95i;U7 zZ2_+9Vza(O@Ds&$P$05ClUeh|01IwvZyn zNi8K6EUSAovouy>H5wak1HB-XTZ?>c%La1%uB1w{8sp#o42m855Ny z+8m=r8sz5!t=Q>x;R_NYv+_875g;JFstzEwcAgU28Dj=`fvG!olfi!0CYh-cg)9d9 zQV~Jua}~t}Hl5(HlVJIXpo6NHPF%u$BB>?y9EjW$Z0PM9*v&w%05wr^d(SQx6g(g0 z3?S=AK|o^IYYM^NbM*)jo!kRHK!)-AfE~Z{+pj|Y=?KfCV2xL~DVnA!+fv9xG&3qO0F zeO|5DzGffhUcr8;O$B_RL-?VMSY|IE2Aw~8apn|Bo>|s^h+ww*)KKmcZ z`bZ5XDyK#J@BB`&QMNBp0~!vXuo{V|{6~v7q=p}gNIj!}y?H)2LsbBLrh>MG9l2AUKUnZR&(z+wJWel#=W9yq4zaKi-oclc+d4 zZyD5m^#>2Ei0Yys#rM!EbS0Exbik36w)1=7wA0D34ZJ;k^r~Y@au;?%CyE_QRA9D= z;KnkB4Rs4rA*=vu!Z7DcUgUf8gs1NPTV+F+iL5Z#W;|H^2-stz)Z_gov_-2K-6WZ) zD&GM8?^gE^RWpS^<(S0XI_aaEJ&&J`55Sc|g6BT|-?;f#O7e;dkEKp*1MR6uP(5-y zpLx8mE~+}&BEGpa${d`YO>*ztJYSAZ@8vFt1t^EVtmsRc6Vvvdw>K3yr__$sBz2v@ zDL1wbH6=O$8r36w#6=ic-p0zj)lWoq$m(;1$*_@r@$BF8GE4@qwUcSa@jly{bycYb zr1pY)R?K>3IaKtR)@ktOqbFK!*Qm$s4dCfQ6R$J0oyDYpec=Nd1=Rl3#;Q&v#Gv=h zQo#{uC$6k5jwWwzoJDvVBToSg0zOH*fSnvD+E2!&88JaE!yUW7~4(x!4v_Rg5-e9?L{*rP~T;*^a_} z%J6eMW;wOwoB_$QeSJIAs`(b$IuIl_*IHy2Li?DPOiajz6ZsQ8*j%)pPQ2Atfcg;) z;n<#jq_K9@OF!9w#uZ&e&jianhXvw9Iy>BvCn2Q{`t-hb}{%{=*RIPMA0d00z z0Kz#q)x)Q9u}#*uI|hnYXgmgEvF_u6a@5+B!>gdYrpb`H&c3UIJ6|h)3>g<9<~8)k zA83DfxSy2BpBYH)SAT#tVxtl)@pzpgqyM4b8Xu-|~w;=~G|SuKcJifs|G?-&*y7ITVFO^IFD7kXgo#Tb76wN)!u4$P!M7$nzb zpkPSm>8)f1xPX+Gp~S!r#%QjKaF=5KxL~x*8)+&?uQ4-iKIoL7>V1FJUuHr&M%~DG zHyk5cfh=WZ{VSlS?;VUUTbTb{{EiTiCYfud%_ShIw&FB1JfCh*ki)+lGt~`fC+6JT zQ&S1lz>s*wPV9Ph1ZnYi2@NW!C>G%uDvrcx&0d=8Z@w&SR?G385Tx~*Cy|52>cVxk; zImqjcuuWWE>dAwR`O$|a#kK=1z55bF(DQ2dgz1otS-C(IFFIhNlYJ^<5g7p=V! zbF{x%;lVM-za#+jg)#4Jt96%9#i$cX)EP1%ncPGE8*xx}zV-jG0BN>XUE3CVzU2TL zQTZ!JatRya3Nm#S*c%&=W18-wchX_yynbJTmjig>O&ug+ooU|=?tD0yxt6#exnd4d z3q}_Dzmd~ywLb*|>wZ7#@taM(f2TTH8!9RUA`-Z`U}y1;_#Se4Gx=D;my##Voj=Pn z&GNecW)b4jlIwJD{j#CW467ioTA=}!5-`burBwR?QklS}P!A&XmEjX4*-kSBMgy=h z3QQV{nbaAQ<(UE54&>@NE%A@jXOZTDC3H8p&H1_r5Qysc=lS?}2lz7I9Z2~8I%0xO z99)3!MUcw9kEn?ZHfZN<>-Ch{SwZE*&H5>bwTrTx3a)M~EGmlDeU0lw2T+a>_)5m` zBJhD9nPh&1iRE@$UUqdyz!>hpWi#%H_Rn(F)1wE0M4=Fi zBLj`b^SFX7Q>9y*pZPX*9EBvOsHX`IawvgKy@zuWwe4(kjx;G>^2|ZqhKJ1+&q4v! zvuNb|dlP0tbJXzpXTP5~qbtD0Hv^_I&|2hmSjM)R!l3vl7hJU0)Pg1Y!L$cVA&Hhc zjCt=NA-ub0zpTA%0>Jc>7?EvQ&CEYG4Jnbq@4)1Nj}o0MiQHKY?TtZZ0Ft08l0iFi z?AWFNg}KhxC;9(At(k~QS)R%7NYppnh55Q{v7_-jYsNeYmXJRHX6iU%szXD(nX)%n zWz0BtHS_hw>aZQDw}#uxRap=?-~)GtD6dj`#+@;JaKlevaARl+DF7b9&-h^z4x5-A+yYw;vH33XQ(N z!2!5|I^zxdBK0R zVL6CCY2RE6Y%V1*-F%LB%IGI*YFJ`-*x#(EjBC`GfLBy2o$!KXyg&f|2_(!JBx)@iDGYm}G4sX}4;> zZI*XK>n~|R+2eVAJ{AO1&RyoYCz#a_@M;4$4qAkEMLt$_KcHmYy%>kicvjkiwm}pu ztVokT4WLlz-#rKnVGbGp!vR$fYrGFixaoV^sVNu($WDOfJ6WtyK9*Y(;LPzX)X%=3 zQ2=@N`uuJVJ6aIA?FF^ELnKmlV1Ex!qa2)T4I|8cx5O1*etp!;Y7avX10Ead;tTy~ zmI`xbmBa|dq7&n0UhUF@_00D)<<$ses;`F?R-6q&-I@a- zsTYrfJ;!b8o&!xbW!94>?O|8vL8)`Fq{`Wk`7~ri^xnj`4hxCdS(ArT$>V^3HXb0g zCNV`++v^lf*oqQg2NOvqiDFDapou42P5q^WN5G>SZF?HpK{Y$5hib4I>c>a%)xTKz z_j*ZDZ{ug`s(ugS@@Pb2lP7DR#iRSYcj&|*$C^*H6)vq2akKi=(P6f>{}l!^Vlv2f zFi(!oYOcmJu=m5glfeTZ6$T)*6}ybHfE-%* ziNUi>%>*)Aa@h@{R`)8#6};td44VuU{jsWD6|N@X(|Bu4z-kjkW-sa1wybfOJUvxV z*1BYxqyN1z1@dZK5wu}BxLa4U-NwyhL4E?Hqi(v^z4JSrNO$r#J_>W+%We<;v$^b? z?UheX?2o3q8(ivI4s!=im#{%{Dt?Gw2ePh1(cuO&$zugZOa+c&2U6C0Bw#!GR}g2)kOsl z#au8pp*HAVGj7)57~;f{T!xvGU;FdLLOMl&N*~z7t?36)3Qmu%cYt<-mh6z4J>tEx zXsftI_F>XAotXX&rrN!)*zb4$0UlS5I9Z?}lY$bP(>6jdhecu&1f^=*JNqvJp!KVJTKp;s_LY7v_8$665^b-(1uI3P+E{y4CT)qM| zB+#>-%>DT>TT%UN#V>PmP6jR{urn(Fj428OgYivSvD%vrc!x_cKGSKJJ4tn>C8UD*;ocH3*o;BgI}Ci~gCPPP zz6<3avV8o;wY>Y^9Xi{*4%in|Jt5atSzE$~VO@a0H7v}Ws?V}lHduO*(C1AID7pO9 zGa4jtxc+_d*Tyuz_OME>HHERGB#22IWYy8+ck=mCNn(PNtwNom>|)1OtZ&aJFbDC$ z<6l8Emigcw?I-;E_~FZ-`$25zi>*vMlOWW{MMLw2%%`(TH>2E#bycXhG|2uR1gQAd zAZ2#S4>-fNh!(9&EP3Z=;6BdyIcRFJ4%?C}?tO_N6^;0lR{DTla^oxa=8~(^K*4Nl z_O)_HMO4{(!6E=TD&Fj2YY3$>Mdy(EBrtH_wArG%sSf7dwGEv9tf{Pord!qIRp423 zr3pN{*&W$@si# zL_s!0Vff;wE1r4k0@K`RRd>d{eEQRCc`y)`P~KN&K1^7v5W^JbcqW%8oXRx&?mX-J zJtolxU{p{dQ?D90p}1MmXY?bG#byq8KF$^O6PTk(BcM+#mLd_3{Z1TP!GmY* z9Q!HzErK`Hm;fN^)qtz9ZlKSmuU30;DS3lZVhA_{+P48HxgRYB-;^sTr(Er6>~W|Z z#ZVl8QN>rW%8K2fPL>Y@Dcf8j2{7@k%>a21AeeLKuU5y|KmM4{3qpswMPoK~by;OM zIUV)9=3PJ#!6LRy8Di?yi?WrN_4xXU<%}Ig+}`EyM1yEUlgPn%_)P@^V)%{_1i|HI zS3X(}tB*w%3eT?jv$Q7w8io5!0GTm!``@2NBdw?QKD^H0Tm9cIn4Y2AW7XHCzG#J@ z1`0isW79#DkFo+~bf^*6oJcn$zI;I%l<^~J_mP*l0|f_Ng14P#fOJ#A86a|M6A0gQ z;r^UY2=?JClvicvc}Mz>SJmq_xZz&TrE`W(sr#7q5=1lK#u}c}AB4cq2LKw*oIl$X z@Klsrhh~tD?H6-h%5pKyC7>?;t{x-YK)p!{0=OMo9~N21AE2(YH|8tlHi#@g$f^H5 zyde(KlbI(rb@Z(wR5KIR%5mvd=)>7t7MKLS&c?c&3|Kxt%RU4Qx5(NvTr zC@;qPdrct{3ax%z~3mFQ(e-db55Jsp8`gHwZw<`xPT1XJy{mqG8@+ zD2lLIuq$bbNFD!xFFi7LQVt4#IHuqjAk?!x<$F}FuF1q7~} zOaW)5M3pafOYX^}!eE#j>L%rgi6|R_`)LXy;dBM4Dig(}T_K#2A3=iZ^2&$IKacUO zH3JCNWxv@_#=X5u2WF_*&jFXWktW@o^W_89z7_Gn_JTPY!oNM&t_zg=h^Q$r98fzs zB3jl1L|4Tt!#tC+>k`wSWop9DzBoYLSqol zeog78eNmKoI%K~Y!DCV{flip^jv;Yaar$-cz3Y5`m5^b(l_FkRc2mpOPbfiPFI5b= zqo!2izlR=7J+|ivUxZe%#bO7eLbASeicyR|DX~KVScC#~qWIkwvvU(d@ z)P{cUaYmbVkW7PTAWsS~!U29MgdO;f8U&c5Gj9Rz7c!(T<_!$3b0q2%G?0?fv zJWe2mqshNU`kTwSss6VqsZChN8c;Q-f;uTV8h;sVzvva``sw6JCykZ#@Sp|+v&FOm zh{9Z$`=BlwjR%p6dQ`R^R;mG!Y%wezWG`#_6Un94?--F2k#3%G4bg-({^SG$5~XF6z6qi6N@-@ zK%@K%`Ex0^5F2_;<^_cCWcM-WTXXUU+>6=o8*e~pETX(bV7gin$y9rDa{mn=nU*7+ zoePP|aVI!k_RN9N^@D<>WslBT|B(4z^B%}|8&OM9puIrd=RUl_3SB1+UJ48KZ2Z>d z_+_O}AHosG0hfi=7VCn}tyVgRKJgB%nJYRYheRGsMDPM-k9h z*)tl83n1=(0=5D-_*#^mxS;o45AQ?JAvp~qhTEWu@OijW_C1(zW|nYjF(xzxlpJJ1 zp24y4oGW5QFw%wrqs{7mAb|hjzy%0s@mJp6doo{rb-lAG;DS`P22KxjlL6CLd33YB z-Uj94H`6IRga%w-Yk-oO;B_8I?qd+el!ZKS4$(fl3GW=-$EpP2lmZirX_zeXCz@39|*m5jH4db``{b}04I#ti;6*l}37n4D6|t`ln02hvSpB{VoI%qha7 zTY?xgd9yh70pFyJ_Sy|eGkf(ebyO94q1DcVY{eEOno+C{JA)KtV^NUE;`8;|h8jdRc}<81m+6}S*Z zZE_SKyRhgc;AUqrw~4MP7~UX&b&hiw^9&5#()p z(=FE)?_@PTw%8zr+e` zo!t|&TB;F-j%6JCH-S;j5l^tL9vI@_7kcwmi&}=vHTt#f>#tmy>m8omQM&m8rG&2O zn=$buBZX&}#2U+T5OcCyVcC(CjvgeynOKpK5v7G$nFFIApt&!|m7(r0(gmH}&3 zO6U@Gm&y+2^@nmZ&xcE>vaSZWz5!hwv#EeajQ7(e%(%?igU(CJ&CG^=E%bG>s#D@I zpsx@vHHPcBPag@?jfcqVe-;7xC=U>0qddiR@T{ArnhlF3Hq4+yB= zj%I|-q0P~uN%s?`J5et;!6y`MOdqrjR;RDVhX?J>e`>y=`JngD=wTUE@2@$)zmOA! zj_)Dq9>qT3Vk5ouN_4~$p8j}ul+hUe|9;O7yFSx-=F^UbiCJUgp%_>iV1TN?kR|zX zD}}?$Kamp!=L^e>x`Zi-rZ~ps^C%8wBCs2n?Xc1Kz6`n_t&2o|&xFe)1?`~H?6{QR zb7Q)wjg6|}*{)@i{P5v9zy=O$1TIFY!`cI511F52&Gq@>Q z%vC(#>&@RgNkZ7ErO~t`l)G$e%Nu8b)7&*Cs=NF0&E7ymG^I_$8)F*f4)&3r{NaQ} z9snKdRI~b}s67eL=9##F3$BupV*vdeBLvf{M~1Is0}m7lXb;<3f(5|61w~ck-B+gp zjI*6Mezlg|6M*C{>e2|8Mm38PvSl`8bKEmhHce}AbXCCwGi^#P-x<6LL%cUXFMYtF z;@E~k{&te-=EHfxlEZQYv{pca*#-Z7o#Y)Qh+Wxu`fpz6gBwO2fElk^rMlW<76YPh zg>#@fW1{s|;e1W#bEs<)kJ`t-vRG|QTIT3ExZmX{r^X9G3w6{hK)T3^zNQ*H`Z}4# zoDEe9C6B2C`bMh~l~p47GjUNOAlR-h&%-W%3-LJDN z%!AL+UiRJ{J5@)A>O7E}P=@2l8wNx^e!b}_ao3XM1kjscO%4poqyA8yAwLThg{!oQ zV-8v1r|PUPxS&JTwjAX!$rS75Q2jD3)i!A1o7efF5GH8SI?%`D@2;3Ri*D2GF)Sf0@D@x!eh2H7{wmcQUn2ox5qGPGfl=%W}iu|o;L%FU@By&AN0S$yN? z%&^}3m)+uyF7}5Dya(-y{)zGV{vWuE3}IC#iYL?6Of6_+Oy|Q300DBv6hlT?3a9lt zvEQ}>fn!4nyd}n6s+m1Id2-vjR3CoS)pl?&_EDXguEa}2trN3&6glFe-~`ZG z{mTIrhn{n)YNvnhWF)?A3LQ zoDHt)G~x8X6xo*M`i9w~5}4{!tpzV3NzuKcc8|IJV&KcXxY&C33KINkOxB&Y{f=bO zlc?v9exQ+7g4eTm?k7Cm23(yn^SN>qpGFs$u6Ftj>ufI^dVJo-t^9m3Pr_dr>@f3* zCt;?_S*~uL_~!EwS;f@^yBk zIm6;bCE)(JuR5U5=o1!sshR^>%-N6&ZlgV@e0LdaAJaG0z5c_hEwsF``R~F0-5Z=s z|Bn3fdEw>r0a>W=BSsL0#tl!~90m306tZy^n{XdbeACpK-Y)X#8(e@)1Ex!I3n;yn zKEzE{$GFuH{aK=(`|vsqf?A(?8P$dhFIuu%i)}}&O&5Y+& zPJb!H9>U%Cie}-X;MI5PHuySCltE(l;M(d#h^p2L`Ss@116Z;YwQSszUwR+V>=0m_J2k;VKZofh-9SJ)!DC{Zg}3;IyW1sES3w`^WNv@vBiV7^ zcRC6gq-^i(VP?O2wY2%8L-OMifZ%PLIg~5Yy4_p zD7!#4nI3>Z~D(ECiv~pqHF|nXYH-Heh|EWFj>x;_$-L+ zz@}F5+k|w<_D`9@Z#Ci(+>f=xM;qNBH`(XON}i$U^Xij__N%V5v1|coN|Szc?#_1Q zcE6|%!8g~p;66XY5O{ge$AZT}QI;`{g0(kYEg{eTd!V)7=b_#K)y)F{Vv%PSv3=+v zXtMfGmokzjgM6OFZK#;4thq(2&XE1#UzWf;2@-Ns#OgnYt~A6DNJO^pAQU|KA^u!8te^t-f=dCE2k zEg>Hxt%N=wcZV3D2Hho>+jN-{e0!nVPBElwaR=@Fn6N?)Q*rO`fW-8_2FjjqnTj&yFWR}?3kSwRj)wVCT6o-lOiV4+&N54V&d%(FJ^3t z6xH9G;-s_y3ijm&5@(fg$)6?vhXp`u6M4oxDi*s%@$-TwnpQ_nG__Fj$v4sMjTakD zj7w{EE~VPL6g3sWHs`|O5@Bn9kWk*)NXCRtZ0hHvE9KefhYe7*aY8%RmIp!S4Mcu& z2!E#yi&|#?$5C}xVfKBIp7w99Z*U78-KB+-9%O z*b*;8_eq+~`JM!HJw<+Uv1gqzW1~>{IqD`^)e%3m&)S{k3JDLhr;TC`bARJqC?C z6%iwCXfq=WlRwQ2;LR8Ih|c59hx7JWp=-4I2?wbd0;b_HEq}}_RrrfWKxvFPn}?Ll z)3ds_S?7k&OFP|dP9k&?@@||jgO;=of<1|49mNdjk?#s?YGh_# zm9Dkp^y$d)cfTUr`h(N>(@|n^-!*C9I~!K+l?OQKBf(}LZHKyh!7No*CkEE`D}lhB zdya&iKa^)J!Bt9LsNm%+sef>ev5qMJfYz2$?z8grTa{}fPfvl*xdy8Bmv~!dGnI4; zPLHLU#)~o8)F+MhR4=?CN{t>tJ0IRQ<#P|SuR4o7LP;1r%=J8k>i^6ptZ6rMJr^Dj zL0$Vz)SccF@`5hJS2&!r%T@w=^*df>0WU2Y_ve{k<5abZj=2V7zB3U!HPu%fvV&~| zey!a_-tn9k+&t*4lfR|tZ~%Da?(0y#a5-fU8}{_7_0P+UeBYc-tV6HcNgRTecbruFagh$=#JL^`s7+8V!+sUw-BnPO>mW5Auac+%NY~- zlggEai-}Oj`tO5f?&!RzivwrU1CvNqt~ea&XRbqt6?7c)!;ZP>f8dP-g^zDopJgxI zx82B6xZ1PEv-MaTAXAB35M^5rT$)LKmEid^&86)9u6O#%Ppn298YOxWFXcd~qx04@ z2HnS%2nj-S$N=;DUyN~S59;K=Tdo6^+Mc<-Ev-1FGSv|8!8}iv6XWYw>n&eG-M!85 zJEl_xb7k2tUm$2A$6mBQc~-uegfL4kO59)_FUGg^C}y7FRUI(DlRY)qN7I{Z=5q@` zh@-cUp zq~SX;GXBP?l(evtA)07ocCYSnp-9QV7QrIVwy3y^6ud&o;4BMi9ym1x$F4#a?8-l{ zo+@uy#=hl((_MM_)F^H{=C`>Sop^i^bqjMaiT|~?)>~4mU?5&-;M-$%HbQaOfi+m? zk1XoDpW6rxL^o3&cSm=OI@G?+Q`}c_b^rbA=mJAP4#8i#<{x!x?P^gV#P{v=TnB#T zlC&Ho=6kHS`uF{t#+{cahIcml^$$!tbi0E8>O`u!v=ZN8^gp!7$|o`WQ$o_Yz9g8c+$QnE^~KBGUzs?I z0okx>BKo))yHUZwt#bm)?KkXk_aduA36$a

TLQcF^WfH^(ZOkBOGW#oAx+SB1L! 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 qRF!xE9w06M? Date: Thu, 7 Jul 2016 02:15:12 +0200 Subject: [PATCH 092/200] Refactor the documentation and make several improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring more to the highest level, so it's easier to navigate. Don't have subpages – it's important to be consistent. This also means less blah-blah index pages. Read through some chunks of the documentation, and make small improvements here and there. Update the readme.md. Take a look at setup.py to prepare for creating package tomorrow. Also mention that dependencies must be mentioned in setup.py inside requirements.txt. Add page about contributing to this project. Add waffle.io button. Highlight the current page in the navigation. Show the logo in the footer when in mobile mode, so mobile readers can navigate to the front page (this isn't possible by default!). --- doc/_static/custom.css | 15 +++++ doc/api.rst | 24 +------ doc/conf.py | 24 +++---- doc/contributing.rst | 90 +++++++++++++++++++++++++++ doc/index.rst | 41 +++++++----- doc/user/basic_usage_guide/index.rst | 18 ------ doc/user/basic_usage_guide/part_1.rst | 6 +- doc/user/basic_usage_guide/part_2.rst | 2 +- doc/user/example.rst | 7 +-- doc/user/fork.rst | 21 ++++--- doc/user/index.rst | 16 ----- doc/user/installation.rst | 20 ++---- readme.md | 16 +++-- requirements.txt | 1 + setup.py | 20 +++--- 15 files changed, 188 insertions(+), 133 deletions(-) create mode 100644 doc/contributing.rst delete mode 100644 doc/user/basic_usage_guide/index.rst delete mode 100644 doc/user/index.rst diff --git a/doc/_static/custom.css b/doc/_static/custom.css index d648527..9c8d85e 100644 --- a/doc/_static/custom.css +++ b/doc/_static/custom.css @@ -4,3 +4,18 @@ body, div.body { pre { background-color: #ebebeb; } +div.sphinxsidebar a.current { + text-decoration: none; + border-bottom: none; + font-weight: bold; + cursor: text; +} +/* Don't hide logo when viewed on mobile, just invert its colors */ +@media screen and (max-width: 875px) { + div.sphinxsidebar p.logo { + display: block; + filter: invert(100%); + width: 60%; + margin: auto; + } +} diff --git a/doc/api.rst b/doc/api.rst index a611d6d..a535034 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -1,28 +1,6 @@ ================= -Developer's Guide -================= - - -------- -Testing -------- - -You can test the module integration-testing-style by simply executing:: - - $ python -m podgen - -When working on this project, you should run the unit tests as well as the -integration test, like this:: - - $ make test - -The unit tests reside in ``podgen/tests`` and are written using the -:mod:`unittest` module. - - ------------------ API Documentation ------------------ +================= .. autosummary:: diff --git a/doc/conf.py b/doc/conf.py index 49a572d..72fdcd7 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -16,7 +16,10 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +needs_sphinx = '1.4' + +# Don't show warnings about the button images not being local +suppress_warnings = ['image.nonlocal_uri'] # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -103,12 +106,11 @@ 'fixed_sidebar': False, 'page_width': "1000px", 'sidebar_width': "225px", - 'show_related': True, 'body_text': "rgba(0, 0, 0, 0.8)", 'footer_text': "rgba(0, 0, 0, 0.5)", 'gray_1': "rgba(0, 0, 0, 0.9)", 'gray_2': "rgba(0, 0, 0, 0.2)", - 'gray_3': "rgba(0, 0, 0, 0.1)", + 'gray_3': "rgba(198, 198, 198, 0.9)", 'github_user': 'tobinus', 'github_repo': 'python-podgen', @@ -134,7 +136,7 @@ # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -html_favicon = "favicon.ico" +html_favicon = "_static/favicon.ico" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -215,13 +217,13 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'pyPodGen.tex', u'pyPodGen Documentation', - u'Lars Kiesow', 'manual'), + ('index', 'pyPodGen.tex', u'PodGen Documentation', + u'Lars Kiesow and Thorben Dahl', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +latex_logo = '_static/logo.png' # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. @@ -231,13 +233,13 @@ #latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +latex_show_urls = True # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +latex_domain_indices = False # -- Options for manual page output -------------------------------------------- @@ -250,7 +252,7 @@ ] # If true, show URL addresses after external links. -#man_show_urls = False +man_show_urls = True # -- Options for Texinfo output ------------------------------------------------ @@ -275,7 +277,7 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping = {'https://docs.python.org/3': None} # Ugly way of setting tabsize diff --git a/doc/contributing.rst b/doc/contributing.rst new file mode 100644 index 0000000..8589bef --- /dev/null +++ b/doc/contributing.rst @@ -0,0 +1,90 @@ +============ +Contributing +============ + +Setting up +---------- + +To install the dependencies, run:: + + $ pip install -r requirements + +while you have a `virtual environment `_ +activated. + +You are recommended to use `pyenv `_ to handle +virtual environments and Python versions. That way, you can easily test and +debug problems that are specific to one version of Python. + +Testing +------- + +You can perform an integration test by running ``podgen/__main__.py``:: + + $ python -m podgen + +When working on this project, you should run the unit tests as well as the +integration test, like this:: + + $ make test + +The unit tests reside in ``podgen/tests`` and are written using the +:mod:`unittest` module. + + +Values +------ + +Read :doc:`/user/introduction` and :doc:`/user/fork` for a run-down on what +values/principles lay the foundation for this project. In short, it is important +to keep the API as simple as possible. + +You must also write unittests as you code, ideally using **test-driven +development** (that is, write a test, observe that the test fails, write code +so the test works, observe that the test succeeds, write a new test and so on). +That way, you know that the tests actually contribute and you get to think +about how the API will look before you tackle the problem head-on. + +Make sure you update ``podgen/__main__.py`` so it still works, and use your new +functionality there if it makes sense. + +You must also make sure you **update any relevant documentation**. Remember that +the documentation includes lots of examples and also describes the API +independently from docstring comments in the code itself. + +Pull requests in which the unittests and/or documentation don't match up the +code will NOT be accepted. + +Lastly, a single **commit** shouldn't include more changes than it needs. It's better to do a big +change in small steps, each of which is one commit. Explain the impact of your +changes in the commit message. + +The Workflow +------------ + +#. Check out `waffle.io `_ or + `GitHub Issues `_. + + * Find the issue you wish to work on. + * Add your issue if it's not already there. + +#. Work on the issue in a separate branch which follows the name scheme + ``tobinus/python-podgen#-`` in your own fork. To be honest, I + don't know if Waffle.io will notice that, but it doesn't hurt to try, I + guess! You might want to read up on `Waffle.io's recommended workflow `_. + +#. Push the branch. + +#. Do the work. + +#. When you're done and you've updated the documentation and tests (see above), + create a pull request which references the issue. + +#. Wait for me or some other team member to review the pull request. Keep an + eye on your inbox or your GitHub notifications, since we may have some + objections or feedback that you must take into consideration. **It'd be a + shame if your work never led to anything because you didn't notice a + comment!** + +#. Consider making the same changes to `python-feedgen `_ + as well. diff --git a/doc/index.rst b/doc/index.rst index 6f38df4..50108b5 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -9,46 +9,53 @@ PodGen :target: http://podgen.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -Wouldn't it be nice if there was a **clean and simple library** which could help you -**generate podcast RSS feeds** from your Python code? Well, today's your lucky day! +.. image:: https://badge.waffle.io/tobinus/python-podgen.svg?label=ready&title=Ready + :target: https://waffle.io/tobinus/python-podgen + :alt: 'Stories in Ready' + +Don't you wish there was a **clean and simple library** which could help you +**generate podcast RSS feeds** with your Python code? Well, today's your lucky day! >>> from podgen import Podcast, Episode, Media >>> # Create the Podcast >>> p = Podcast( - name="My Awesome Podcast", + name="The Library Tuesday Talk", description="My friends and I discuss Python" " libraries each Tuesday!", website="http://example.org/awesomepodcast" ) >>> # Add some episodes >>> p.episodes += [ - Episode(title="PodGen rocks!", + Episode(title="Worry about timezones no more", media=Media("http://example.org/ep1.mp3", 11932295), - summary="I found an awesome library for creating podcasts"), + summary="Using pytz, you can make your code timezone-aware " + "with very little hassle."), Episode(title="Heard about clint?", media=Media("http://example.org/ep2.mp3", 15363464), summary="The man behind Requests made something useful " - "for us command-line lovers." + "for us command-line nerds." ] >>> # Generate the RSS feed >>> rss = str(p) You don't need to read the RSS specification, write XML by hand or wrap your -head around ambiguous, undocumented APIs. Just provide the data, and PodGen -fixes the rest for you! - -Where to start --------------- +head around ambiguous, undocumented APIs. PodGen incorporates the industry's +best practices and lets you focus on collecting the necessary metadata and +publishing the podcast. -Take a look at the :doc:`user/example` for a larger example, read about -:doc:`the project's background ` or refer to -the :doc:`user/basic_usage_guide/index` for a detailed introduction to PodGen. -Contents --------- +User Guide +---------- .. toctree:: :maxdepth: 3 - user/index + user/introduction + user/installation + user/fork + user/basic_usage_guide/part_1 + user/basic_usage_guide/part_2 + user/basic_usage_guide/part_3 + user/example + contributing api diff --git a/doc/user/basic_usage_guide/index.rst b/doc/user/basic_usage_guide/index.rst deleted file mode 100644 index 24157b1..0000000 --- a/doc/user/basic_usage_guide/index.rst +++ /dev/null @@ -1,18 +0,0 @@ -Basic usage guide -================= - -When using PodGen, you can divide your program into -three phases: - -.. toctree:: - :maxdepth: 1 - - part_1 - part_2 - part_3 - -While the -:doc:`../example` gives you a practical introduction, this document helps you -understand what the different attributes mean and how they should be used. -It complements the :doc:`/api` nicely. - diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index e87c6c7..d9df0ea 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -1,5 +1,5 @@ -Populating the podcast ----------------------- +Creating the podcast +-------------------- Creating a new instance ~~~~~~~~~~~~~~~~~~~~~~~ @@ -104,7 +104,7 @@ Read more: * :attr:`~podgen.Podcast.withhold_from_itunes` Shortcut for filling in data -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Instead of creating a new :class:`.Podcast` object in one statement, and populating it with data one statement at a time afterwards, you can create a diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index 62d920d..c984a59 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -209,7 +209,7 @@ More details: Shortcut for filling in data -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Instead of assigning those values one at a time, you can assign them all in one go in the constructor – just like you can with Podcast. Just use the diff --git a/doc/user/example.rst b/doc/user/example.rst index 6fd156b..8a575a8 100644 --- a/doc/user/example.rst +++ b/doc/user/example.rst @@ -2,13 +2,10 @@ Working example =============== -Below is a working example of how you can go about using PodGen. It -also shows you how you can use the different properties of Podcast and Episode. +This example is located at ``podgen/__main__.py`` in the package, and is run +as part of the :doc:`testing routines `. .. literalinclude:: ../../podgen/__main__.py :pyobject: main :linenos: -Once you understand the basic way you do things, you're ready to look at the -:doc:`/api` in conjunction with the :doc:`basic_usage_guide/index` to see exactly what properties you can set, and how they -affect the end result. diff --git a/doc/user/fork.rst b/doc/user/fork.rst index 0e4ee34..4ac948c 100644 --- a/doc/user/fork.rst +++ b/doc/user/fork.rst @@ -2,13 +2,15 @@ Why the fork? ============= -This project is a fork of ``python-feedgen`` which cuts away everything that +This project is a fork of python-feedgen_ which cuts away everything that doesn't serve the goal of **making it easy and simple to generate podcasts** from a Python program. Thus, this project includes only a **subset** of the features -of ``python-feedgen``. And I don't think anyone in their right mind would accept a pull +of python-feedgen_. And I don't think anyone in their right mind would accept a pull request which removes 70% of the features ;-) Among other things, support for ATOM and Dublin Core is removed, and the remaining code is almost entirely rewritten. +A more detailed reasoning follows. Read it if you're interested, but feel free +to skip to :doc:`basic_usage_guide/part_1`. Inspiration ----------- @@ -31,7 +33,8 @@ They also cause bugs, since it is so difficult to wrap your head around how one interact with another. Removing ATOM fixes all these issues. -Even then, ``python-feedgen`` aims at being comprehensive, which means you must +Even then, python-feedgen_ aims at being comprehensive, and gives you a one-to-one +mapping to the resulting XML elements. This means that you must learn the RSS and podcast standards, which include many legacy elements you don't really need. For example, the original RSS spec includes support for an image, but that image is required to be less than 144 pixels @@ -45,7 +48,7 @@ image must be larger than 1400x1400 pixels, not the history behind everything. Alignment with the philosophies ------------------------------- -``python-feedgen``'s code breaks all the philosophies listed above: +python-feedgen_'s code breaks all the philosophies listed in the :doc:`introduction`: #. Beautiful is better than ugly, yet all properties are set through hybrid setter/getter methods. @@ -56,7 +59,8 @@ Alignment with the philosophies are available as methods of the extension's name, which suddenly is available as a property of your FeedGenerator object. #. Complex is better than complicated, yet an entire framework is built to - handle extensions, rather than using class inheritance. + handle extensions, rather than using class inheritance. (Said framework + even requires that the extension resides inside a specific folder!) #. Readability counts, yet classes are named after their function and not what they represent, and (again) properties are set through methods. @@ -71,7 +75,7 @@ Summary of changes ------------------ * ``FeedGenerator`` is renamed to :class:`~podgen.Podcast` and ``FeedItem`` is accessed - at ``Podcast.Episode`` (or directly: :class:`~podgen.BaseEpisode`). + at ``Episode``. * Support for ATOM removed. * Move from using getter and setter methods to using properties, which you can assign just like you would assign any other property. @@ -79,7 +83,7 @@ Summary of changes * Compound values (like managingEditor or enclosure) use classes now. -* Remove support for some uncommon elements: +* Remove support for some uncommon or unnecessary elements: * ttl * category @@ -94,3 +98,6 @@ Summary of changes * Add shorthand for generating the RSS: Just try to converting your :class:`~podgen.Podcast` object to :obj:`str`! * Improve the documentation +* Move away from the extension framework, and rely on class inheritance instead. + +.. _python-feedgen: https://github.com/lkiesow/python-feedgen diff --git a/doc/user/index.rst b/doc/user/index.rst deleted file mode 100644 index 9754233..0000000 --- a/doc/user/index.rst +++ /dev/null @@ -1,16 +0,0 @@ -========== -User Guide -========== - - -New to PodGen? This guide will get you up to speed on how this fork -came to be, its license as well as how to install and start using it. - -.. toctree:: - :maxdepth: 2 - - introduction - fork - installation - basic_usage_guide/index - example diff --git a/doc/user/installation.rst b/doc/user/installation.rst index 5166b95..c46b4af 100644 --- a/doc/user/installation.rst +++ b/doc/user/installation.rst @@ -2,21 +2,11 @@ Installation ============ -#. Clone the `GitHub repository`_. +Use `pip `_:: -#. Ensure your project has a virtualenv. + $ pip install podgen -#. Activate your project's virtualenv. +Just a word of warning: PodGen depends on +`lxml `_, which can take several minutes to build. -#. Install the requirements listed in ``requirements.txt`` inside podgen:: - - pip install -r requirements.txt - -#. Add this library to the Python path, and you should be able to use it. - - -This is a pretty bad way to install something, but I haven't had the time to -set up a PyPi package yet. Until then, you'd be better off using the original -python-feedgen. - -.. _GitHub repository: https://github.com/tobinus/python-podgen/tree/podcastgen +Remember to `use a virtual environment `_! diff --git a/readme.md b/readme.md index d9dfdfa..d3d14ad 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,10 @@ PodGen (forked from python-feedgen) =================================== -[![Build Status](https://travis-ci.org/tobinus/python-podgen.svg?branch=master)](https://travis-ci.org/tobinus/python-podgen) [![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) +[![Build Status](https://travis-ci.org/tobinus/python-podgen.svg?branch=master)](https://travis-ci.org/tobinus/python-podgen) +[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) +[![Stories in Ready](https://badge.waffle.io/tobinus/python-podgen.svg?label=ready&title=Ready)](http://waffle.io/tobinus/python-podgen) + This module can be used to generate podcast feeds in RSS format, and is compatible with Python 3.3+. @@ -14,17 +17,12 @@ at license.bsd and license.lgpl. More details about the project: - Repository: https://github.com/tobinus/python-podgen -- Documentation: http://lkiesow.github.io/python-feedgen/ +- Documentation: https://podgen.readthedocs.io/ - Python Package Index: https://pypi.python.org/pypi/podgen/ ------------- -Installation ------------- - -Currently, you'll need to clone this repository, and create a virtualenv and -install lxml and dateutils. - +See the documentation link above for installation instructions and +guides on how to use this module. ---------- Known bugs diff --git a/requirements.txt b/requirements.txt index e92b9b9..db067d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +# Remember to add any new requirements to setup.py as well! dateutils lxml pytz diff --git a/setup.py b/setup.py index bba2731..e6e9815 100755 --- a/setup.py +++ b/setup.py @@ -9,15 +9,14 @@ packages = ['podgen'], version = podgen.version.version_full_str, description = 'Generating podcasts with Python should be easy!', - author = 'Lars Kiesow', - author_email = 'lkiesow@uos.de', - url = 'http://lkiesow.github.io/python-feedgen', + author = 'Thorben W. S. Dahl', + author_email = 'thorben@sjostrom.no', + url = 'http://podgen.readthedocs.io/en/latest/', keywords = ['feed','RSS','podcast','iTunes'], license = 'FreeBSD and LGPLv3+', - install_requires = ['lxml', 'dateutils'], + install_requires = ['lxml', 'dateutils', 'future', 'pytz'], classifiers = [ 'Development Status :: 4 - Beta', - 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: Science/Research', @@ -26,9 +25,13 @@ 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Topic :: Communications', 'Topic :: Internet', + 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Text Processing', 'Topic :: Text Processing :: Markup', 'Topic :: Text Processing :: Markup :: XML' @@ -40,9 +43,10 @@ This module can be used to easily generate Podcasts. It is designed so you don't need to read up on how RSS and iTunes functions – it just works! -See the documentation at .... +See the documentation at http://podgen.readthedocs.io/en/latest/ for more +information. -It is licensed under the terms of both, the FreeBSD license and the LGPLv3+. +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. ''' From b64235724c83c3150f371bcdfd268784307f8cb0 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 7 Jul 2016 15:07:41 +0200 Subject: [PATCH 093/200] Make plenty of small improvements to the documentation --- doc/conf.py | 4 +- doc/contributing.rst | 6 +- doc/index.rst | 2 +- doc/user/basic_usage_guide/part_1.rst | 58 ++++++++++++++---- doc/user/basic_usage_guide/part_2.rst | 86 +++++++++++++++------------ doc/user/basic_usage_guide/part_3.rst | 23 ++++--- doc/user/example.rst | 6 +- doc/user/fork.rst | 40 +++++++++---- doc/user/installation.rst | 2 +- doc/user/introduction.rst | 17 +++--- podgen/version.py | 4 +- 11 files changed, 158 insertions(+), 90 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 72fdcd7..37953c4 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -233,7 +233,7 @@ #latex_show_pagerefs = False # If true, show URL addresses after external links. -latex_show_urls = True +latex_show_urls = "true" # Documents to append as an appendix to all manuals. #latex_appendices = [] @@ -277,7 +277,7 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/3': None} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} # Ugly way of setting tabsize diff --git a/doc/contributing.rst b/doc/contributing.rst index 8589bef..8620b47 100644 --- a/doc/contributing.rst +++ b/doc/contributing.rst @@ -52,8 +52,8 @@ You must also make sure you **update any relevant documentation**. Remember that the documentation includes lots of examples and also describes the API independently from docstring comments in the code itself. -Pull requests in which the unittests and/or documentation don't match up the -code will NOT be accepted. +Pull requests in which the unittests and documentation are NOT up to date +with the code will NOT be accepted. Lastly, a single **commit** shouldn't include more changes than it needs. It's better to do a big change in small steps, each of which is one commit. Explain the impact of your @@ -67,6 +67,8 @@ The Workflow * Find the issue you wish to work on. * Add your issue if it's not already there. + * Discuss the issue and get feedback on your proposed solution. Don't waste + time on a solution that might not be accepted! #. Work on the issue in a separate branch which follows the name scheme ``tobinus/python-podgen#-`` in your own fork. To be honest, I diff --git a/doc/index.rst b/doc/index.rst index 50108b5..5e01b6b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -22,7 +22,7 @@ Don't you wish there was a **clean and simple library** which could help you name="The Library Tuesday Talk", description="My friends and I discuss Python" " libraries each Tuesday!", - website="http://example.org/awesomepodcast" + website="http://example.org/librarytuesdaytalk" ) >>> # Add some episodes >>> p.episodes += [ diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index d9df0ea..4d52cd8 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -9,7 +9,7 @@ Creating a new instance from podgen import Podcast p = Podcast() -Mandatory properties +Mandatory attributes ~~~~~~~~~~~~~~~~~~~~ :: @@ -19,12 +19,12 @@ Mandatory properties p.website = "https://example.org" p.explicit = True -Those four properties, :attr:`~podgen.Podcast.name`, -:attr:`~podgen.Podcast.description`, -:attr:`~podgen.Podcast.explicit` and -:attr:`~podgen.Podcast.website`, are actually -the only four **mandatory** properties of -:class:`~podgen.Podcast`. +They're self explanatory, but you can read more about them if you'd like: + +* :attr:`~podgen.Podcast.name` +* :attr:`~podgen.Podcast.description` +* :attr:`~podgen.Podcast.website` +* :attr:`~podgen.Podcast.explicit` Image ~~~~~ @@ -38,10 +38,10 @@ A podcast's image is worth special attention:: Even though the image *technically* is optional, you won't reach people without it. -Optional properties +Optional attributes ~~~~~~~~~~~~~~~~~~~ -There are plenty of other properties that can be used with +There are plenty of other attributes that can be used with :class:`podgen.Podcast `: @@ -53,7 +53,7 @@ Commonly used p.copyright = "2016 Example Radio" p.language = "en-US" p.authors = [Person("John Doe", "editor@example.org")] - p.feed_url = "https://example.com/feeds/podcast.rss" + p.feed_url = "https://example.com/feeds/podcast.rss" # URL of this feed p.category = Category("Technology", "Podcasting") p.owner = p.authors[0] @@ -75,20 +75,51 @@ again have very reasonable defaults. :: + # RSS Cloud enables podcatchers to subscribe to notifications when there's + # a new episode ready, however it's not used much. p.cloud = ("server.example.com", 80, "/rpc", "cloud.notify", "xml-rpc") import datetime + # pytz is a dependency of this library, and makes it easy to deal with + # timezones. Generally, all dates must be timezone aware. import pytz + # last_updated is datetime when the feed was last refreshed. If you don't + # set it, the current date and time will be used instead when the feed is + # generated, which is generally what you want. Nevertheless, you can + # set your own date: p.last_updated = datetime.datetime(2016, 5, 18, 0, 0, tzinfo=pytz.utc)) - p.publication_date = datetime.datetime(2016, 5, 17, 15, 32, tzinfo=pytz.utc)) + # publication_date is when the contents of this feed last were published. + # If you don't set it, the date of the most recent Episode is used. Again, + # this is generally what you want, but you can override it: + p.publication_date = datetime.datetime(2016, 5, 17, 15, 32,tzinfo=pytz.utc)) + + # Set of days on which podcatchers won't need to refresh the feed. + # Not implemented widely. p.skip_days = {"Friday", "Saturday", "Sunday"} + + # Set of hours on which podcatchers won't need to refresh the feed. + # Not implemented widely. p.skip_hours = set(range(8)) p.skip_hours |= set(range(16, 24)) + + # Person to contact regarding technical aspects of the feed. p.web_master = Person(None, "helpdesk@dallas.example.com") - # Be very careful about using the following attributes: + + # Identify the software which generates the feed (defaults to python-podgen) + p.set_generator("ExamplePodcastProgram", (1,0,0)) + # (you can also set the generator string directly) + p.generator = "ExamplePodcastProgram v1.0.0 (with help from python-feedgen)" + + # !!! Be very careful about using the following attributes !!! + + # Tell iTunes that this feed has moved somewhere else. p.new_feed_url = "https://podcast.example.com/example" + + # Tell iTunes that this feed will never be updated again. p.complete = True + + # Tell iTunes that you'd rather not have this feed appear on iTunes. p.withhold_from_itunes = True Read more: @@ -99,6 +130,7 @@ Read more: * :attr:`~podgen.Podcast.skip_days` * :attr:`~podgen.Podcast.skip_hours` * :attr:`~podgen.Podcast.web_master` +* :meth:`~podgen.Podcast.set_generator` * :attr:`~podgen.Podcast.new_feed_url` * :attr:`~podgen.Podcast.complete` * :attr:`~podgen.Podcast.withhold_from_itunes` @@ -118,6 +150,8 @@ use the attribute name as keyword arguments to the constructor:: ... ) +Using this technique, you can define the Podcast as part of a list +comprehension, dictionaries and so on. Take a look at the :doc:`API Documentation for Podcast ` for a practical example. diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index c984a59..c2fcbaa 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -4,25 +4,33 @@ Adding episodes To add episodes to a feed, you need to create new :class:`podgen.Episode` objects and -append them to the list of entries in the Podcast. That is pretty +append them to the list of episodes in the Podcast. That is pretty straight-forward:: - from podgen import Episode + from podgen import Podcast, Episode + # Create the podcast (see the previous section) + p = Podcast() + # Create new episode my_episode = Episode() + # Add it to the podcast p.episodes.append(my_episode) There is a convenience method called :meth:`Podcast.add_episode ` which optionally creates a new instance of :class:`~podgen.Episode`, adds it to the podcast and returns it, allowing you to assign it to a variable:: + from podgen import Podcast + p = Podcast() my_episode = p.add_episode() If you prefer to use the constructor, there's nothing wrong with that:: + from podgen import Podcast, Episode + p = Podcast() my_episode = p.add_episode(Episode()) -The advantage of using the latter form, is that you can pass data to the -constructor, which can make your code more compact and readable. +The advantage of using the latter form is that you can pass data to the +constructor. Filling with data ~~~~~~~~~~~~~~~~~ @@ -63,14 +71,26 @@ Of course, this isn't much of a podcast if we don't have any duration=timedelta(hours=1, minutes=2, seconds=36) ) -Normally, you must specify how big the **file size** is in bytes (and the MIME -type, if the file extension is unknown to iTunes), but PodcastGenerator +The **type** of the media file is derived from the URI ending, if you don't +provide it yourself. Even though you +technically can have file names which don't end in their actual file extension, +iTunes will use the file extension to determine what type of file it is, without +even asking the server. You must therefore make sure your media files have the +correct file extension. If you don't care about compatibility with iTunes, you +can provide the MIME type yourself. + +The **duration** is also important to include for your listeners' convenience. +Without it, they won't know how long an episode is before they start downloading +and listening. It must be an instance of :class:`datetime.timedelta`. + +Normally, you must specify how big the **file size** is in bytes (and the `MIME +type`_ if the file extension is unknown to iTunes), but PodGen can send a HEAD request to the URL and retrieve the missing information -(file size and type). This is done by calling +(both file size and type). This is done by calling :meth:`Media.create_from_server_response ` instead of using the constructor directly. You must pass in the `requests `_ -module, so it must be installed! :: +module, so make sure it's installed. :: import requests my_episode.media = Media.create_from_server_response( @@ -79,27 +99,16 @@ module, so it must be installed! :: duration=timedelta(hours=1, minutes=2, seconds=36) ) +.. note:: -The **type** of the media file is derived from the URI ending, if you don't -provide it yourself. Even though you -technically can have file names which don't end in their actual file extension, -iTunes will use the file extension to determine what type of file it is, without -even asking the server. You must therefore make sure your media files have the -correct file extension. If you don't care about compatibility with iTunes, you -can provide the MIME type yourself. -:meth:`Media.create_from_server_response ` -will also fetch the type for you, if it's not specified. - -The **duration** is also important to include, for your listeners' convenience. -Without it, they won't know how long an episode is before they start downloading -and listening. The duration cannot be fetched from the server automatically, and -must be an instance of :class:`datetime.timedelta`. + The duration cannot be fetched from the server automatically. Read more about: * :attr:`podgen.Episode.media` (the attribute) * :class:`podgen.Media` (the class which you use as value) +.. _MIME type: https://en.wikipedia.org/wiki/Media_type Identifying the episode ^^^^^^^^^^^^^^^^^^^^^^^ @@ -129,16 +138,15 @@ will get a new episode which appears to have existed for longer than it has. .. note:: It is generally a bad idea to use the media file's modification date - as the publication date when you make your episodes some time in advance - – your listeners will suddenly get an "old" episode in - their feed! + as the publication date. If you make your episodes some time in advance, your + listeners will suddenly get an "old" episode in their feed! :: my_episode.publication_date = datetime.datetime(2016, 5, 18, 10, 0, tzinfo=pytz.utc) -Read more about :attr:`the publication_date attribute `. +Read more about :attr:`the publication_date attribute `. The Link @@ -163,20 +171,13 @@ Read more about :attr:`the link attribute `. The Authors ^^^^^^^^^^^ -.. note:: - - Some of the following attributes (not just authors) correspond to attributes - found in :class:`~podgen.Podcast`. In such cases, you should only set those - attributes at the episode level if they **differ** from their value at the - podcast level. - Normally, the attributes :attr:`Podcast.authors ` and :attr:`Podcast.web_master ` (if set) are used to determine the authors of an episode. Thus, if all your episodes have the same authors, you should just set it at the podcast level. -If an episode's authors differs from the podcast's, though, you can override it -like this:: +If an episode's list of authors differs from the podcast's, though, you can +override it like this:: my_episode.authors = [Person("Joe Bob")] @@ -192,11 +193,20 @@ Less used attributes :: + # Not actually implemented by iTunes; the Podcast's image is used. my_episode.image = "http://example.com/static/best-example.png" + + # Set it to override the Podcast's explicit attribute for this episode only. my_episode.explicit = False - my_episode.is_closed_captioned = False # Only applicable for video + + # Tell iTunes that the enclosed video is closed captioned. + my_episode.is_closed_captioned = False + + # Tell iTunes that this episode should be the first episode on the store + # page. my_episode.position = 1 - # Be careful about using the following attribute! + + # Careful! This will hide this episode from the iTunes store page. my_episode.withhold_from_itunes = True More details: @@ -225,4 +235,4 @@ See also the example in :doc:`the API Documentation `. -------------------------------------------------------------------------------- -The final step is :doc:`part_3` +The final step is :doc:`part_3`. diff --git a/doc/user/basic_usage_guide/part_3.rst b/doc/user/basic_usage_guide/part_3.rst index 6596b71..b28cb03 100644 --- a/doc/user/basic_usage_guide/part_3.rst +++ b/doc/user/basic_usage_guide/part_3.rst @@ -2,35 +2,40 @@ Generating the RSS ------------------ -Once you've added all the information and all episodes, it's time to +Once you've added all the information and episodes, you're ready to take the final step:: - rssfeed = p.rss_str() + rssfeed = p.rss_str() # Print to stdout, just as an example print(rssfeed) If you're okay with the default parameters of :meth:`podgen.Podcast.rss_str`, -you can use a shortcut by converting :class:`~podgen.Podcast` to :obj:`str`:: +you can use a shortcut by converting your :class:`~podgen.Podcast` to :obj:`str`:: rssfeed = str(p) + print(rssfeed) # Or let print convert to str for you print(p) -Doing so is the same as calling :meth:`podgen.Podcast.rss_str` with no -parameters. - .. autosummary:: ~podgen.Podcast.rss_str You may also write the feed to a file directly, using :meth:`podgen.Podcast.rss_file`:: - fg.rss_file('rss.xml', minimize=True) + p.rss_file('rss.xml', minimize=True) .. autosummary:: ~podgen.Podcast.rss_file -This concludes the basic usage guide. You might want to look at the -:doc:`../example` or the :doc:`/api`. +.. note:: + + If there are any mandatory attributes that aren't set, you'll get errors + when generating the RSS. + +.. note:: + + Generating the RSS is not completely free. Save the result to a variable + once instead of generating the same RSS over and over. diff --git a/doc/user/example.rst b/doc/user/example.rst index 8a575a8..37ee96b 100644 --- a/doc/user/example.rst +++ b/doc/user/example.rst @@ -1,6 +1,6 @@ -=============== -Working example -=============== +============ +Full example +============ This example is located at ``podgen/__main__.py`` in the package, and is run as part of the :doc:`testing routines `. diff --git a/doc/user/fork.rst b/doc/user/fork.rst index 4ac948c..b0fc276 100644 --- a/doc/user/fork.rst +++ b/doc/user/fork.rst @@ -19,19 +19,19 @@ The reason I felt like making such drastic changes, is that the original library **exceptionally hard to learn** and use. Error messages would not tell you what was wrong, the concept of extensions is poorly explained and the methods are a bit weird, in that they function as getters and setters at the same time. The fact that you have three -separate ways to go about setting multi-value variables, is also a bit confusing. +separate ways to go about setting multi-value variables is also a bit confusing. Perhaps the biggest problem, though, is the awkwardness that stems from enabling RSS and ATOM feeds through the same API. In case you don't know, ATOM is a -"competitor" to RSS, and has many more capabilities than RSS. However, it is -not used for podcasting. It is confusing because some methods will map an ATOM value to +competitor to RSS, and has many more capabilities than RSS. However, it is +not used for podcasting. The result of mixing both ATOM and RSS include methods that will map an ATOM value to its closest sibling in RSS, some in logical ways (like the ATOM method ``rights`` setting the value of the RSS property ``copyright``) and some differ in subtle ways (like using (ATOM) ``logo`` versus (RSS) ``image``). Other methods are more complex (see ``link``). They're all confusing, though, since changing one property automatically changes another implicitly. They also cause bugs, since it is so difficult to wrap your head around how one interact with another. -Removing ATOM fixes all these issues. +Removing ATOM support fixes all these issues. Even then, python-feedgen_ aims at being comprehensive, and gives you a one-to-one mapping to the resulting XML elements. This means that you must @@ -74,16 +74,29 @@ bring it there, so it can benefit **everyone**. Summary of changes ------------------ -* ``FeedGenerator`` is renamed to :class:`~podgen.Podcast` and ``FeedItem`` is accessed - at ``Episode``. -* Support for ATOM removed. -* Move from using getter and setter methods to using properties, which you can - assign just like you would assign any other property. +If you've used python-feedgen_ and want to move over to PodGen, you might as +well be moving to a completely different library. Everything has been renamed, +some attributes expect :obj:`bool` where they earlier expected :obj:`str`, and +so on – you'll have to forget whatever you've learnt about the library. +Hopefully, the simple API should ease the pain of switching, and make the +resulting code easier to maintain. - * Compound values (like managingEditor or enclosure) use - classes now. +The following list is not exhaustive. -* Remove support for some uncommon or unnecessary elements: +* The module is renamed from ``feedgen`` to ``podgen``. +* ``FeedGenerator`` is renamed to :class:`~podgen.Podcast` and ``FeedItem`` is + renamed to :class:`~podgen.Episode`. +* All classes are available at package level, so you no longer need to import + them from the module they reside in. For example, :class:`podgen.Podcast` and + :class:`podgen.Episode`. +* Support for ATOM is removed. +* Stop using getter and setter methods and start using attributes. + + * Compound values (like :attr:`~podgen.Podcast.managing_editor` or + :attr:`~podgen.Episode.media`) expect + objects now, like :class:`~podgen.Person` and :class:`~podgen.Media`. + +* Remove support for some uncommon, obsolete or difficult to use elements: * ttl * category @@ -95,9 +108,10 @@ Summary of changes * Rename the remaining properties so their names don't necessarily match the RSS elements they map to. Instead, the names should be descriptive and easy to understand. +* :attr:`.Podcast.explicit` is now required, and is :obj:`bool`. * Add shorthand for generating the RSS: Just try to converting your :class:`~podgen.Podcast` object to :obj:`str`! -* Improve the documentation +* Improve the documentation (as you've surely noticed). * Move away from the extension framework, and rely on class inheritance instead. .. _python-feedgen: https://github.com/lkiesow/python-feedgen diff --git a/doc/user/installation.rst b/doc/user/installation.rst index c46b4af..b91b64c 100644 --- a/doc/user/installation.rst +++ b/doc/user/installation.rst @@ -9,4 +9,4 @@ Use `pip `_:: Just a word of warning: PodGen depends on `lxml `_, which can take several minutes to build. -Remember to `use a virtual environment `_! +Remember to use a `virtual environment `_! diff --git a/doc/user/introduction.rst b/doc/user/introduction.rst index 2b82217..87541fd 100644 --- a/doc/user/introduction.rst +++ b/doc/user/introduction.rst @@ -9,10 +9,10 @@ Philosophy This project is heavily inspired by the wonderful `Kenneth Reitz `__, known for the -`Requests `__ library, which features an API which is +`Requests `__ library, which features an API that is as beautiful as it is effective. Watching his `"Documentation is King" talk `__, -I wanted to make some of the libraries I'm using suitable for use by actual humans. +I wanted to make some of the libraries I'm using suitable for human consumption too. This project is to be developed following the same `PEP 20 `__ idioms as @@ -30,9 +30,9 @@ To enable this, the project focuses on one task alone: making it easy to generat Scope ----- -This library does NOT help you publish a podcast, or manage podcasts. It's just -a tool that takes information about your podcast, and outputs an RSS feed which -you can then publish however you want. +This library does NOT help you publish a podcast, or manage the metadata of your +podcasts. It's just a tool that accepts information about your podcast and +outputs an RSS feed which you can then publish however you want. Both the process of getting information about your podcast, and publishing it needs to be done by you. Even then, @@ -45,12 +45,15 @@ PodGen is geared towards developers who aren't super familiar with RSS and XML. If you know exactly how you want the XML to look, then you're better off using a template engine like Jinja2 (even if friends don't let friends touch XML bare-handed). If you just want an easy way to create and -manage your podcasts, use `Podcast Generator `. +manage your podcasts, use `Podcast Generator `_. ------- License ------- PodGen 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. +at license.bsd_ and license.lgpl_. + +.. _license.bsd: https://github.com/tobinus/python-podgen/blob/master/license.bsd +.. _license.lgpl: https://github.com/tobinus/python-podgen/blob/master/license.lgpl diff --git a/podgen/version.py b/podgen/version.py index b492c7d..ccacf85 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -25,7 +25,7 @@ version_full_str = '.'.join([str(x) for x in version_full]) 'Name of this project' -name = "python-podgen (podcastgen)" +name = "python-podgen" 'Website of this project' -website = "https://github.com/tobinus/python-podgen/tree/podcastgen" +website = "https://podgen.readthedocs.org" From 7f8acce077f60d53d6f03403377551c17bd9a662 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 7 Jul 2016 21:55:42 +0200 Subject: [PATCH 094/200] Fix bug which allowed forbidden values in skip* elements --- podgen/podcast.py | 4 ++++ podgen/tests/test_podcast.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/podgen/podcast.py b/podgen/podcast.py index 7a1b737..f23aa20 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -420,11 +420,15 @@ def _create_rss(self): pubDate.text = formatRFC2822(actual_pubDate) if self.skip_hours: + # Ensure any modifications to the set are accounted for + self.skip_hours = self.skip_hours skipHours = etree.SubElement(channel, 'skipHours') for h in self.skip_hours: hour = etree.SubElement(skipHours, 'hour') hour.text = str(h) if self.skip_days: + # Ensure any modifications to the set are accounted for + self.skip_days = self.skip_days skipDays = etree.SubElement(channel, 'skipDays') for d in self.skip_days: day = etree.SubElement(skipDays, 'day') diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index 65b49f6..bca5786 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -417,5 +417,17 @@ def test_withholdFromItunes(self): .find("{%s}block" % self.nsItunes) assert itunes_block is None + def test_modifyingSkipDaysAfterwards(self): + self.fg.skip_days.add("Unrecognized day") + self.assertRaises(ValueError, self.fg.rss_str) + self.fg.skip_days.remove("Unrecognized day") + self.fg.rss_str() # Now it works + + def test_modifyingSkipHoursAfterwards(self): + self.fg.skip_hours.add(26) + self.assertRaises(ValueError, self.fg.rss_str) + self.fg.skip_hours.remove(26) + self.fg.rss_str() # Now it works + if __name__ == '__main__': unittest.main() From f88b0af5c72a2566233e56be746f192f5a653ab1 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 7 Jul 2016 21:56:49 +0200 Subject: [PATCH 095/200] Make massive amount of improvements to Podcast's API Documentation Additionally, rename earlier forgotten mentions of feedgen to podgen, namely in podgen.Category's example and the exclude_feedgen argument in Podcast.set_generator. --- podgen/category.py | 2 +- podgen/podcast.py | 309 +++++++++++++++++++++++------------ podgen/tests/test_podcast.py | 2 +- 3 files changed, 202 insertions(+), 111 deletions(-) diff --git a/podgen/category.py b/podgen/category.py index 6b8b398..015e583 100644 --- a/podgen/category.py +++ b/podgen/category.py @@ -12,7 +12,7 @@ class Category(object): Example:: - >>> from podgen.category import Category + >>> from podgen import Category >>> c = Category("Music") >>> c.category Music diff --git a/podgen/podcast.py b/podgen/podcast.py index f23aa20..87c1c79 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -36,10 +36,15 @@ class Podcast(object): * :attr:`~podgen.Podcast.description` * :attr:`~podgen.Podcast.explicit` + All attributes can be assigned :obj:`None` in addition to the types + specified below. Types etc. are checked during assignment, to help you + discover errors earlier. Duck typing is employed wherever a class in podgen + is expected. + There is a **shortcut** you can use when creating new Podcast objects, that lets you populate the attributes using the constructor. Use keyword arguments with the **attribute name as keyword** and the desired value as - value:: + value. As an example:: >>> import podgen >>> # The following... @@ -71,26 +76,31 @@ def __init__(self, **kwargs): # http://www.rssboard.org/rss-specification # Mandatory: self.name = None - """The name of the podcast. It should be a human - readable title. Often the same as the title of the - associated website. This is mandatory for RSS and must - not be blank. + """The name of the podcast as a :obj:`str`. It should be a human + readable title. Often the same as the title of the associated website. + This is mandatory and must not be blank. - This will set rss:title. + :type: :obj:`str` + :RSS: title """ self.website = None - """This podcast's website's absolute URL. + """The absolute URL of this podcast's website. - One of the mandatory attributes. + This is one of the mandatory attributes. - This corresponds to the RSS link element. + :type: :obj:`str` + :RSS: link """ self.description = None - """The description of the feed, which is a phrase or sentence describing - the channel. It is mandatory for RSS feeds, and is shown under the - podcast's name on the iTunes store page.""" + """The description of the podcast, which is a phrase or sentence + describing it to potential new subscribers. It is mandatory for RSS + feeds, and is shown under the podcast's name on the iTunes store page. + + :type: :obj:`str` + :RSS: description + """ self.explicit = None """Whether this podcast may be inappropriate for children or not. @@ -104,7 +114,11 @@ def __init__(self, **kwargs): in the Name column in iTunes. If it is set to ``False``, 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.""" + "clean" graphic will appear. + + :type: :obj:`bool` + :RSS: itunes:explicit + """ # Optional: self.__cloud = None @@ -120,13 +134,19 @@ def __init__(self, **kwargs): you do not need a copyright statement for something to be protected by copyright. If you intend to put the podcast in public domain or license it under a Creative Commons license, you should say so in the copyright - notice.""" + notice. + + :type: :obj:`str` + :RSS: copyright""" self.__docs = 'http://www.rssboard.org/rss-specification' self.generator = self._feedgen_generator_str """A string identifying the software that generated this RSS feed. - Defaults to a string identifying PodcastGenerator. + Defaults to a string identifying PodGen. + + :type: :obj:`str` + :RSS: generator .. seealso:: @@ -144,7 +164,11 @@ def __init__(self, **kwargs): It must be a two-letter code, as found in ISO639-1, with the possibility of specifying subcodes (eg. en-US for American English). See http://www.rssboard.org/rss-language-codes and - http://www.loc.gov/standards/iso639-2/php/code_list.php""" + http://www.loc.gov/standards/iso639-2/php/code_list.php + + :type: :obj:`str` + :RSS: language + """ self.__last_updated = None @@ -170,14 +194,16 @@ def __init__(self, **kwargs): the iTunes catalogue to implement the search feature. Listeners will still be able to subscribe by adding the feed's address manually. - If you don't intend on submitting this podcast to iTunes, you can set - this to True as a way of showing iTunes the middle finger (and prevent - others from submitting it as well). + If you don't intend to submit this podcast to iTunes, you can set + this to ``True`` as a way of giving iTunes the middle finger, and + perhaps more importantly, preventing others from submitting it as well. Set it to ``True`` to withhold the entire podcast from iTunes. It is set to ``False`` by default, of course. - :type: bool""" + :type: :obj:`bool` + :RSS: itunes:block + """ self.__category = None @@ -188,10 +214,13 @@ def __init__(self, **kwargs): self.new_feed_url = None """When set, tell iTunes that your feed has moved to this URL. - After adding the tag to your old feed, you should maintain the old feed + After adding this attribute, 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. + :type: :obj:`str` + :RSS: itunes:new-feed-url + .. warning:: iTunes supports this mechanic of changing your feed's location. @@ -203,7 +232,7 @@ def __init__(self, **kwargs): .. warning:: - Make sure the new URL here is correct, or else you're making + Make sure the new URL you set is correct, or else you're making people switch to a URL that doesn't work! """ @@ -212,7 +241,11 @@ def __init__(self, **kwargs): self.subtitle = None """The subtitle for your podcast, shown mainly as a very short description on iTunes. The subtitle displays best if it is only a few - words long, like a short slogan.""" + words long, like a short slogan. + + :type: :obj:`str` + :RSS: itunes:subtitle + """ # Populate the podcast with the keyword arguments for attribute, value in iteritems(kwargs): @@ -226,10 +259,13 @@ def __init__(self, **kwargs): @property def episodes(self): - """List of episodes that are part of this podcast. + """List of :class:`.Episode` objects that are part of this podcast. See :py:meth:`.add_episode` for an easy way to create new episodes and assign them to this podcast in one call. + + :type: :obj:`list` of :class:`podgen.Episode` + :RSS: item elements """ return self.__episodes @@ -244,32 +280,39 @@ def episode_class(self): """Class used to represent episodes. This is used by :py:meth:`.add_episode` when creating new episode - objects, and you may use it too when creating episodes. + objects, and you, too, may use it when creating episodes. - By default, this property points to :py:class:`Episode`. + By default, this property points to :py:class:`.Episode`. - When assigning a new class to ``episode_class``, you must make sure the - new value (1) is a class and not an instance, and (2) is a subclass of - Episode (or is Episode itself). + When assigning a new class to ``episode_class``, you must make sure that + the new value (1) is a class and not an instance, and (2) that it is a + subclass of Episode (or is Episode itself). Example of use:: >>> # Create new podcast >>> from podgen import Podcast, Episode >>> p = Podcast() + >>> # Normal way of creating new episodes >>> episode1 = Episode() >>> p.episodes.append(episode1) + >>> # Or use add_episode (and thus episode_class indirectly) >>> episode2 = p.add_episode() + >>> # Or use episode_class directly >>> episode3 = p.episode_class() >>> p.episodes.append(episode3) + >>> # Say you want to use AlternateEpisode class instead of Episode >>> from mymodule import AlternateEpisode >>> p.episode_class = AlternateEpisode + >>> episode4 = p.add_episode() >>> episode4.title("This is an instance of AlternateEpisode!") + + :type: :obj:`class` which extends :class:`podgen.Episode` """ return self.__episode_class @@ -292,20 +335,18 @@ def add_episode(self, new_episode=None): object if it's not provided, and returns it. This is the easiest way to add episodes to a podcast. - :param new_episode: Episode object to add. A new instance of - self.Episode is used if new_episode is omitted. + :param new_episode: :class:`.Episode` object to add. A new instance of + :attr:`.episode_class` is used if ``new_episode`` is omitted. :returns: Episode object created or passed to this function. Example:: ... - >>> entry = feedgen.add_episode() - >>> entry.title('First feed entry') - 'First feed entry' + >>> episode1 = p.add_episode() + >>> episode1.title = 'First episode' >>> # You may also provide an episode object yourself: - >>> another_entry = feedgen.add_episode(podgen.Episode()) - >>> another_entry.title('My second feed entry') - 'My second feed entry' + >>> another_episode = p.add_episode(podgen.Episode()) + >>> another_episode.title = 'My second episode' Internally, this method creates a new instance of :attr:`~podgen.Episode.episode_class`, which means you can change what @@ -496,18 +537,18 @@ def __str__(self): def rss_str(self, minimize=False, encoding='UTF-8', xml_declaration=True): - """Generates an RSS feed and returns the feed XML as string. + """Generate an RSS feed and return the feed XML as string. :param minimize: Set to True to disable splitting the feed into multiple lines and adding properly indentation, saving bytes at the cost of - readability. + readability (default: False). :type minimize: bool :param encoding: Encoding used in the XML file (default: UTF-8). :type encoding: str - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). + :param xml_declaration: Whether an XML declaration should be added to + the output (default: True). :type xml_declaration: bool - :returns: String representation of the RSS feed. + :returns: The generated RSS feed as a :obj:`str`. """ feed = self._create_rss() return etree.tostring(feed, pretty_print=not minimize, encoding=encoding, @@ -516,19 +557,20 @@ def rss_str(self, minimize=False, encoding='UTF-8', def rss_file(self, filename, minimize=False, encoding='UTF-8', xml_declaration=True): - """Generates an RSS feed and write the resulting XML to a file. + """Generate 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. :type filename: str or fd :param minimize: Set to True to disable splitting the feed into multiple lines and adding properly indentation, saving bytes at the cost of - readability. + readability (default: False). :type minimize: bool - :param encoding: Encoding used in the XML file (default: UTF-8). + :param encoding: Encoding used in the XML file (default: UTF-8). :type encoding: str - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). + :param xml_declaration: Whether an XML declaration should be added to + the output (default: True). :type xml_declaration: bool + :returns: Nothing. """ feed = self._create_rss() doc = etree.ElementTree(feed) @@ -537,20 +579,22 @@ def rss_file(self, filename, minimize=False, @property def last_updated(self): - """The last time the feed was modified in a significant way. Most often, - it is taken to mean the last time the feed was generated, which is why - it defaults to the time and date at which the RSS is generated, if set - to None. The default should be sufficient for most, if not all, use - cases. - - 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 corresponds to rss:lastBuildDate. Set this to False to have no - lastBuildDate element in the feed (and thus suppress the default). - - :type: :obj:`str`, :class:`datetime.datetime` or :obj:`None`. + """The last time the feed was generated. It defaults to the time and + date at which the RSS is generated, if set to :obj:`None`. The default + should be sufficient for most, if not all, use cases. + + The value can either be a string, which will automatically be parsed + into a :class:`datetime.datetime` object when assigned, or a + :class:`datetime.datetime` object. In any case, the time and date must + be timezone aware. + + Set this to ``False`` to leave out this element instead of using the + default. + + :type: :class:`datetime.datetime`, :obj:`str` (will be converted to + and stored as :class:`datetime.datetime`), :obj:`None` for default or + :obj:`False` to leave out. + :RSS: lastBuildDate """ return self.__last_updated @@ -570,11 +614,11 @@ def last_updated(self, last_updated): @property def cloud(self): """The cloud data of the feed, as a 5-tuple. It specifies a web service - that supports the rssCloud interface which can be implemented in - HTTP-POST, XML-RPC or SOAP 1.1. + that supports the (somewhat dated) rssCloud interface, which can be + implemented in HTTP-POST, XML-RPC or SOAP 1.1. - The tuple should look like this: ``(domain, port, path, registerProcedure, - protocol)``. + The tuple should look like this: ``(domain, port, path, + registerProcedure, protocol)``. :domain: The domain where the webservice can be found. :port: The port the webservice listens to. @@ -587,6 +631,10 @@ def cloud(self): p.cloud = ("podcast.example.org", 80, "/rpc", "cloud.notify", "xml-rpc") + :type: :obj:`tuple` with (:obj:`str`, :obj:`int`, :obj:`str`, + :obj:`str`, :obj:`str`) + :RSS: cloud + .. tip:: PubSubHubbub is a competitor to rssCloud, and is the preferred @@ -614,15 +662,19 @@ def cloud(self, cloud): self.__cloud = None def set_generator(self, generator=None, version=None, uri=None, - exclude_feedgen=False): + exclude_podgen=False): """Set the generator of the feed, formatted nicely, which identifies the - software used to generate the feed, for debugging and other purposes. + software used to generate the feed. :param generator: Software used to create the feed. + :type generator: str :param version: (Optional) Version of the software, as a tuple. - :param uri: (Optional) URI the software can be found. - :param exclude_feedgen: (Optional) Set to True to disable the mentioning - of the python-podgen library. + :type version: :obj:`tuple` of :obj:`int` + :param uri: (Optional) The software's website. + :type uri: str + :param exclude_podgen: (Optional) Set to True if you don't want + PodGen to be mentioned (e.g., "My Program (using PodGen 1.0.0)") + :type exclude_podgen: bool .. seealso:: @@ -632,7 +684,7 @@ def set_generator(self, generator=None, version=None, uri=None, """ self.generator = self._program_name_to_str(generator, version, uri) + \ (" (using %s)" % self._feedgen_generator_str - if not exclude_feedgen else "") + if not exclude_podgen else "") def _program_name_to_str(self, generator=None, version=None, uri=None): return generator + \ @@ -677,6 +729,9 @@ def authors(self): >>> # Or they can be given as new list (overriding earlier authors) >>> p.authors = [Person("John Doe", "johndoe@example.org"), ... Person("Mary Sue", "marysue@example.org")] + + :type: :obj:`list` of :class:`podgen.Person` + :RSS: managingEditor or dc:creator, and itunes:author """ return self.__authors @@ -691,21 +746,26 @@ def authors(self, authors): @property def publication_date(self): - """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 publication date of the - channel changes. - - :type: None, a string which will automatically be parsed or a - datetime.datetime object. In any case it is necessary that the value - include timezone information. - :Default value: If this is None when the feed is generated, the - publication date of the episode with the latest publication date (which - may be in the future) is used. If there are no episodes, the publication - date is omitted from the feed. + """The publication date for the content in this podcast. You + probably want to use the default value. + + :Default value: If this is :obj:`None` when the feed is generated, the + publication date of the episode with the latest publication date + (which may be in the future) is used. If there are no episodes, the + publication date is omitted from the feed. + + If you set this to a :obj:`str`, it will be parsed and made into a + :class:`datetime.datetime` object when assigned. You may also set it to + a :class:`datetime.datetime` object directly. In any case, the time and + date must be timezone aware. If you want to forcefully omit the publication date from the feed, set this to ``False``. + + :type: :class:`datetime.datetime`, :obj:`str` (will be converted to + and stored as :class:`datetime.datetime`), :obj:`None` for default or + :obj:`False` to leave out. + :RSS: pubDate """ return self.__publication_date @@ -722,11 +782,17 @@ def publication_date(self, publication_date): @property def skip_hours(self): - """Set of hours in which feed readers don't need to refresh this feed. + """Set of hours of the day in which podcatchers don't need to refresh + this feed. + + This isn't widely supported by podcatchers. The hours are represented as integer values from 0 to 23. + Note that while the content of the set is checked when it is first + assigned to ``skip_hours``, further changes to the set "in place" will + not be checked before you generate the RSS. - For example, to skip hours between 18 and 7:: + For example, to stop refreshing the feed between 18 and 7:: >>> from podgen import Podcast >>> p = Podcast() @@ -736,6 +802,9 @@ def skip_hours(self): >>> p.skip_hours |= set(range(8)) >>> p.skip_hours {0, 1, 2, 3, 4, 5, 6, 7, 18, 19, 20, 21, 22, 23} + + :type: :obj:`set` of :obj:`int` + :RSS: skipHours """ return self.__skip_hours @@ -753,17 +822,24 @@ def skip_hours(self, hours): def skip_days(self): """Set of days in which podcatchers don't need to refresh this feed. - The days are represented using strings of their dayname, like "Monday" - or "wednesday". + This isn't widely supported by podcatchers. + + The days are represented using strings of their English names, like + "Monday" or "wednesday". The day names are automatically capitalized + when the set is assigned to ``skip_days``, but subsequent changes to the + set "in place" are only checked and capitalized when the RSS feed is + generated. - For example, to skip the weekend:: + For example, to stop refreshing the feed in the weekend:: >>> from podgen import Podcast >>> p = Podcast() - >>> p.skip_days = {"Friday", "Saturday", "sunday"} + >>> p.skip_days = {"Friday", "Saturday", "sUnDaY"} >>> p.skip_days {"Saturday", "Friday", "Sunday"} + :type: :obj:`set` of :obj:`str` + :RSS: skipDays """ return self.__skip_days @@ -784,6 +860,9 @@ def skip_days(self, days): def web_master(self): """The :class:`~podgen.Person` responsible for technical issues relating to the feed. + + :type: :class:`podgen.Person` + :RSS: webMaster """ return self.__web_master @@ -798,9 +877,10 @@ def web_master(self, web_master): @property def category(self): """The iTunes category, which appears in the category column - and in iTunes Store Browser. + and in iTunes Store listings. - Use the :class:`podgen.Category` class. + :type: :class:`podgen.Category` + :RSS: itunes:category """ return self.__category @@ -819,22 +899,24 @@ def category(self, category): @property def image(self): - """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. + """The URL of the artwork for this podcast. iTunes + prefers square images that are at least ``1400x1400`` pixels. + Podcasts with an image smaller than this are *not* eligible to be + featured on the iTunes Store. 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 iTunes to be able to automatically update your cover art. + (CMYK is not supported). The URL must end in ".jpg" or ".png". + + :type: :obj:`str` + :RSS: itunes:image + + .. note:: + + If you change your podcast’s image, you must also change the file’s + name; iTunes doesn't check the image to see if it has changed. + + Additionally, the server hosting your cover art image must allow HTTP + HEAD requests (most servers support this). """ return self.__image @@ -857,11 +939,14 @@ def complete(self): episodes will be added to the podcast. If you let this be ``None`` or ``False``, you are indicating that new episodes may be posted. + :type: :obj:`bool` + :RSS: itunes:complete + .. warning:: Setting this to ``True`` is the same as promising you'll never ever release a new episode. Do NOT set this to ``True`` as long as - there's any chance at all that a new episode will be released + there's any chance AT ALL that a new episode will be released someday. """ @@ -877,11 +962,14 @@ def complete(self, complete): @property def owner(self): """The :class:`~podgen.Person` who owns this podcast. iTunes - will use this information to contact the owner of the podcast for - communication specifically about the podcast. It will not be publicly - displayed, but it will be in the feed source. + will use this person's name and email address for all correspondence + related to this podcast. It will not be publicly displayed, but it's + still publicly available in the RSS source. Both the name and email are required. + + :type: :class:`podgen.Person` + :RSS: itunes:owner """ return self.__owner @@ -902,6 +990,9 @@ def feed_url(self): Identifying a feed's URL within the feed makes it more portable, self-contained, and easier to cache. You should therefore set this attribute if you're able to. + + :type: :obj:`str` + :RSS: atom:link with ``rel="self"`` """ return self.__feed_url diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index bca5786..a0faf5c 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -191,7 +191,7 @@ def test_generator(self): assert self.programname in generator # Using set_generator, text excludes python-podgen - self.fg.set_generator(software_name, exclude_feedgen=True) + self.fg.set_generator(software_name, exclude_podgen=True) generator = self.fg._create_rss().find("channel").find("generator").text assert software_name in generator assert self.programname not in generator From 048d1b17aad6571e7660a7c5066a73ad6e6e9c71 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 00:05:59 +0200 Subject: [PATCH 096/200] Finish improving the documentation The other modules have now had their documentation read through and improved. This concludes the work on improving the documentation for now. --- podgen/category.py | 10 ++- podgen/episode.py | 175 ++++++++++++++++++++++++++++++--------------- podgen/media.py | 64 +++++++++++------ podgen/person.py | 18 +++-- podgen/util.py | 32 ++++++++- 5 files changed, 213 insertions(+), 86 deletions(-) diff --git a/podgen/category.py b/podgen/category.py index 015e583..2d18b32 100644 --- a/podgen/category.py +++ b/podgen/category.py @@ -95,13 +95,19 @@ def __init__(self, category, subcategory=None): @property def category(self): - """The category represented by this object. Read-only.""" + """The category represented by this object. Read-only. + + :type: :obj:`str` + """ return self.__category # Make this attribute read-only by not implementing setter @property def subcategory(self): - """The subcategory this object represents. Read-only.""" + """The subcategory this object represents. Read-only. + + :type: :obj:`str` + """ return self.__subcategory # Make this attribute read-only by not implementing setter diff --git a/podgen/episode.py b/podgen/episode.py index 17c6ac1..baeede5 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -40,7 +40,7 @@ class Episode(object): ValueError if you set an attribute to an invalid value. You must have filled in either :attr:`.title` or :attr:`.summary` before - the RSS is generated. + the RSS can be generated. To add an episode to a podcast:: @@ -51,12 +51,12 @@ class Episode(object): You may also replace the last two lines with a shortcut:: - >>> episode = p.add_episode(Episode()) + >>> episode = p.add_episode(podgen.Episode()) .. seealso:: - The :doc:`Basic Usage Guide ` + :doc:`/user/basic_usage_guide/part_2` A friendlier introduction to episodes. """ @@ -79,19 +79,24 @@ def __init__(self, **kwargs): up to 4000 characters in length. See also :py:attr:`.Episode.subtitle` and - :py:attr:`.Episode.long_summary`.""" + :py:attr:`.Episode.long_summary`. + + :type: :obj:`str` which can be parsed as XHTML. + :RSS: description""" self.long_summary = None """A long (read: full) summary, which supplements the shorter - :attr:`~podgen.Episode.summary`. + :attr:`~podgen.Episode.summary`. Like summary, this must be compatible + with XHTML parsers; use :func:`podgen.htmlencode` if this isn't HTML. This attribute should be seen as a full, longer variation of summary if summary exists. Even then, the long_summary should be independent from summary, in that you only need to read one of them. This means you may have to repeat the first sentences. - If summary does not exist but this does, this is used in place of - summary.""" + :type: :obj:`str` which can be parsed as XHTML. + :RSS: content:encoded or description + """ self.__media = None @@ -101,7 +106,7 @@ def __init__(self, **kwargs): If not present, the URL of the enclosed media is used. This is usually the best way to go, **as long as the media URL doesn't change**. - Set the id to boolean False if you don't want to associate any id to + Set the id to boolean ``False`` if you don't want to associate any id to this episode. It is important that an episode keeps the same ID until the end of time, @@ -116,17 +121,28 @@ def __init__(self, **kwargs): domain which you own (for example, use something like http://example.org/podcast/episode1 if you own example.org). - This property corresponds to the RSS GUID element.""" + :type: :obj:`str`, :obj:`None` to use default or :obj:`False` to leave + out. + :RSS: guid + """ self.link = None - """The link to the full version of this episode description. - Remember to start the link with the scheme, e.g. https://.""" + """The link to the full version of this episode's :attr:`.summary`. + Remember to start the link with the scheme, e.g. https://. + + :type: :obj:`str` + :RSS: link + """ self.__publication_date = None self.title = None """This episode's human-readable title. - Title is mandatory and should not be blank.""" + Title is mandatory and should not be blank. + + :type: :obj:`str` + :RSS: title + """ # ITunes tags # http://www.apple.com/itunes/podcasts/specs.html#rss @@ -138,12 +154,15 @@ def __init__(self, **kwargs): self.__explicit = None - self.is_closed_captioned = None - """Whether this podcast includes a video episode with embedded closed - captioning support. + self.is_closed_captioned = False + """Whether this podcast includes a video episode with embedded `closed + captioning`_ support. Defaults to ``False``. - The two values for this tag are ``True`` and - ``False``.""" + :type: :obj:`bool` + :RSS: itunes:isClosedCaptioned + + .. _closed captioning: https://en.wikipedia.org/wiki/Closed_captioning + """ self.__position = None @@ -151,7 +170,11 @@ def __init__(self, **kwargs): """A short subtitle. This is shown in the Description column in iTunes. - The subtitle displays best if it is only a few words long.""" + The subtitle displays best if it is only a few words long. + + :type: :obj:`str` + :RSS: itunes:subtitle + """ # It is time to assign the keyword arguments for attribute, value in iteritems(kwargs): @@ -162,7 +185,13 @@ def __init__(self, **kwargs): "recognized!" % (attribute, value)) def rss_entry(self): - """Create a RSS item and return it.""" + """Create an RSS item using lxml's etree and return it. + + This is primarily used by :class:`podgen.Podcast` when generating the + podcast's RSS feed. + + :returns: etree.Element('item') + """ ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' DUBLIN_NS = 'http://purl.org/dc/elements/1.1/' @@ -279,8 +308,8 @@ def authors(self): """List of :class:`~podgen.Person` that contributed to this episode. - The authors don't need to have both name and email set. The names are - shown under the podcast's title on iTunes. + The authors don't need to have both name and email set. They're usually + not displayed anywhere. .. note:: @@ -308,6 +337,9 @@ def authors(self): >>> # Or assign a new list (discarding earlier authors) >>> ep.authors = [Person("John Doe", "johndoe@example.org"), ... Person("Mary Sue", "marysue@example.org")] + + :type: :obj:`list` of :class:`podgen.Person` + :RSS: author or dc:creator, and itunes:author """ return self.__authors @@ -322,11 +354,22 @@ def authors(self, authors): @property def publication_date(self): - """Set or get the time that this episode first was made public. + """The time and date this episode was first published. + + The value can be a :obj:`str`, which will be parsed and + made into a :class:`datetime.datetime` object when assigned. You may + also assign a :class:`datetime.datetime` object directly. In both cases, + you must ensure that the value includes timezone information. + + :type: :obj:`str` (will be converted to and stored as + :class:`datetime.datetime`) or :class:`datetime.datetime`. + :RSS: pubDate + + .. note:: - The value can either be a string which will automatically be parsed or a - datetime.datetime object. In both cases you must ensure that the value - includes timezone information. + Don't use the media file's modification date as the publication + date, unless they're the same. It looks very odd when an episode + suddenly pops up in the feed, but it claims to be several hours old! """ return self.__publication_date @@ -348,13 +391,16 @@ def media(self): """Get or set the :class:`~podgen.Media` object that is attached to this episode. - Note that if :py:attr:`.id` is not set, the enclosure's url is used as - the globally unique identifier. If you rely on this, you should make - sure the url never changes, since changing the id messes up with clients - (they will think this episode is new again, even if the user already - has listened to it). Therefore, you should only rely on this behaviour - if you own the domain which the episodes reside on. If you don't, then - you must set :py:attr:`.id` to an appropriate value manually. + Note that if :py:attr:`.id` is not set, the media's URL is used as + the id. If you rely on this, you should make sure the URL never changes, + since changing the id messes up with clients (they will think this + episode is new again, even if the user has listened to it already). + Therefore, you should only rely on this behaviour if you own the domain + which the episodes reside on. If you don't, then you must set + :py:attr:`.id` to an appropriate value manually. + + :type: :class:`podgen.Media` + :RSS: enclosure and itunes:duration """ return self.__media @@ -374,17 +420,20 @@ def media(self, media): @property def withhold_from_itunes(self): - """Get or set the iTunes block attribute. Use this to prevent episodes - from appearing in the iTunes podcast directory. Note that the episode - can still be found by inspecting the XML, so it is still public. + """Prevent this episode from appearing in the iTunes podcast directory. + Note that the episode can still be found by inspecting the XML, so it is + still public. - One use case is if you know that this episode will get you kicked + One use case would be if you knew that this episode would get you kicked out from iTunes, should it make it there. In such cases, you can set withhold_from_itunes to ``True`` so this episode isn't published on iTunes, allowing you to publish it to everyone else while keeping your podcast on iTunes. This attribute defaults to ``False``, of course. + + :type: :obj:`bool` + :RSS: itunes:block """ return self.__withhold_from_itunes @@ -401,29 +450,35 @@ def withhold_from_itunes(self, withhold_from_itunes): @property def image(self): - """The podcast episode's image. + """The podcast episode's image, overriding the podcast's + :attr:`~.Podcast.image`. + + This attribute specifies the absolute URL to the artwork for your + podcast. iTunes prefers square images that are 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". + + :type: :obj:`str` + :RSS: itunes:image + + .. note:: + + If you change an episode’s image, you should also change the file’s + name; iTunes doesn't check the actual file to see if it's changed. + + Additionally, the server hosting your cover art image must allow HTTP + HEAD requests. .. warning:: Almost no podcatchers support this. iTunes supports it only if you embed the cover in the media file (the same way you would embed - an album cover), and recommends you use Garageband's Enhanced - Podcast feature. If you don't, the podcast's image is used instead. + an album cover), and recommends that you use Garageband's Enhanced + Podcast feature. - This tag specifies the artwork for your podcast. - 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 you change an episode’s image, you should 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 iTunes to be able to automatically update - your cover art. + The podcast's image is used if this isn't supported. """ return self.__image @@ -444,13 +499,16 @@ def explicit(self): inappropriate for children. The value of the podcast's explicit attribute is used by default, if - this is ``None``. + this is kept as ``None``. If you set this to ``True``, an "explicit" parental advisory graphic will appear in the Name column in iTunes. If the value is ``False``, the parental advisory type is considered Clean, meaning that no explicit language or adult content is included anywhere in this episode, and a "clean" graphic will appear. + + :type: :obj:`bool` + :RSS: itunes:explicit """ return self.__explicit @@ -471,9 +529,14 @@ def position(self): If you would like this episode to appear first, set it to ``1``. If you want it second, set it to ``2``, and so on. If multiple episodes - share the same position, they will be sorted by their publication date. + share the same position, they will be sorted by their + :attr:`publication date <.Episode.publication_date>`. + + To remove the order from the episode, set the position back to + :obj:`None`. - To remove the order from the episode, set the position back to ``None``. + :type: :obj:`int` + :RSS: itunes:order """ return self.__position diff --git a/podgen/media.py b/podgen/media.py index e5fdb06..558bc2f 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -12,12 +12,15 @@ class Media(object): A media file can be a sound file (most typical), video file or a document. - You should provide the url at which this media can be found, and the media's - file size in bytes. Optionally, you can provide the type of media - (expressed in the media type format). When not given in the constructor, it - will be found automatically by looking at the url's file extension. If the - url's file extension isn't supported by iTunes, you will get an error if you - don't supply the type. + You should provide the absolute URL at which this media can be found, and + the media's file size in bytes. + + Optionally, you can provide the type of media (expressed using MIME types). + When not given in the constructor, it will be found automatically by looking + at the url's file extension. If the url's file extension isn't supported by + iTunes, you will get an error if you don't supply the type. + + You are also highly encouraged to provide the duration of the media. .. note:: @@ -27,7 +30,7 @@ class Media(object): .. note:: - A warning called :class:`~podgen.not_supported_by_itunes_warning.NotSupportedByItunesWarning` + A warning called :class:`~podgen.NotSupportedByItunesWarning` will be issued if your URL or type isn't compatible with iTunes. See the Python documentation for more details on :mod:`warnings`. @@ -77,7 +80,10 @@ def url(self): Only absolute URLs are allowed, so make sure it starts with http:// or https://. The server should support HEAD-requests and byte-range - requests.""" + requests. + + :type: :obj:`str` + """ return self._url @url.setter @@ -99,18 +105,21 @@ def url(self, url): def size(self): """The media's file size in bytes. - You can either provide the number of bytes as an int, or you can - provide a human-readable str with a unit, like MB or GiB. + You can either provide the number of bytes as an :obj:`int`, or you can + provide a human-readable :obj:`str` with a unit, like MB or GiB. An unknown size is represented as 0. This should ONLY be used in exceptional cases, where it is theoretically impossible to determine the file size (for example if it's a stream). Setting the size to 0 will issue a UserWarning. + :type: :obj:`str` (which will be converted to and stored as :obj:`int`) + or :obj:`int` + .. note:: If you provide a string, it will be translated to int when the - assignment happens. Thus, on subsequent access, you will get the + assignment happens. Thus, on subsequent accesses, you will get the resulting int, not the string you put in. .. note:: @@ -168,14 +177,16 @@ def type(self): See https://en.wikipedia.org/wiki/Media_type for an introduction. + :type: :obj:`str` + .. note:: If you leave out type when creating a new Media object, the - type will be auto-detected from the :attr:`~podgen.media.Media.url` + type will be auto-detected from the :attr:`~podgen.Media.url` attribute. However, this won't happen automatically other than during initialization. If you want to autodetect type when assigning a new value to url, you should use - :meth:`~podgen.media.Media.get_type`. + :meth:`~podgen.Media.get_type`. """ return self._type @@ -192,11 +203,15 @@ def type(self, type): self._type = type def get_type(self, url): - """Autodetect the media type, given the url. + """Guess the MIME type from the URL. + + This is used to fill in :attr:`~.Media.type` when it is not given (and + thus called implicitly by the constructor), but you can call it + yourself. Example:: - >>> from podgen.media import Media + >>> from podgen import Media >>> m = Media("http://example.org/1.mp3", 136532744) >>> # The type was detected from the url: >>> m.type @@ -210,6 +225,11 @@ def get_type(self, url): >>> m.type = m.get_type(m.url) >>> m.type audio/x-m4a + + :param url: The URL which should be used to guess the MIME type. + :type url: str + :returns: The guessed MIME type. + :raises: ValueError if the MIME type couldn't be guessed from the URL. """ file_extension = urlparse(url).path.split(".")[-1] try: @@ -227,9 +247,8 @@ def duration(self): :type: :class:`datetime.timedelta` :raises: :obj:`TypeError` if you try to assign anything other than - :class:`datetime.timedelta` instances or None to this attribute. - Raises :obj:`ValueError` if a negative timedelta value is - given. + :class:`datetime.timedelta` or :obj:`None` to this attribute. Raises + :obj:`ValueError` if a negative timedelta value is given. """ return self._duration @@ -252,7 +271,10 @@ def duration_str(self): This is just an alternate, read-only view of :attr:`.duration`. - If :attr:`.duration` is :obj:`None`, this will be :obj:`None` as well. + If :attr:`.duration` is :obj:`None`, then this will be :obj:`None` as + well. + + :type: :obj:`str` """ if self.duration is None: return None @@ -279,7 +301,7 @@ def create_from_server_response(cls, requests, url, size=None, type=None, Example (assuming the server responds with Content-Length: 252345991 and Content-Type: audio/mpeg):: - >>> from podgen.media import Media + >>> from podgen import Media >>> import requests # from requests package >>> # Assume an episode is hosted at example.com >>> m = Media.create_from_server_response(requests, @@ -302,7 +324,7 @@ def create_from_server_response(cls, requests, url, size=None, type=None, :type type: str or None :param duration: The media's duration. :type duration: :class:`datetime.timedelta` or :obj:`None`. - :returns: New instance of Media with all fields filled in. + :returns: New instance of Media with url, size and type filled in. :raises: The appropriate requests exceptions are thrown when networking errors occur. RuntimeError is thrown if some information isn't given and isn't found in the server's response.""" diff --git a/podgen/person.py b/podgen/person.py index 3a93f2f..9100ec6 100644 --- a/podgen/person.py +++ b/podgen/person.py @@ -1,8 +1,8 @@ class Person(object): """Data-oriented class representing a single person or entity. - A Person can represent both real persons and organizations, entities - and so on. Example:: + A Person can represent both real persons and less personal entities like + organizations. Example:: >>> p.authors = [Person("Example Radio", "mail@example.org")] @@ -13,8 +13,8 @@ class Person(object): .. warning:: - **Any names and email addresses** you put into a Person object, will - eventually be included in the feed and thus **published** together with + **Any names and email addresses** you put into a Person object will + eventually be included and **published** together with the feed. If you want to keep a name or email address private, then you must make sure it isn't used in a Person object (or to be precise: that the Person object with the name or email address isn't used in any @@ -58,7 +58,10 @@ def _is_valid(self, name, email): @property def name(self): - """This person's name.""" + """This person's name. + + :type: :obj:`str` + """ return self.__name @name.setter @@ -71,7 +74,10 @@ def name(self, new_name): @property def email(self): - """This person's public email address.""" + """This person's public email address. + + :type: :obj:`str` + """ return self.__email @email.setter diff --git a/podgen/util.py b/podgen/util.py index 7c3aab3..196eb39 100644 --- a/podgen/util.py +++ b/podgen/util.py @@ -23,6 +23,7 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): :param defaults: Dictionary with default values. :returns: List of checked dictionaries. """ + # TODO: Check if this function is obsolete and perhaps remove it if not val: return None if allowed_values is None: @@ -63,7 +64,16 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): def formatRFC2822(d): - """Make sure the locale setting do not interfere with the time format. + """Format a datetime according to RFC2822. + + This implementation exists as a workaround to ensure that the locale setting + does not interfere with the time format. For example, day names might get + translated to your local language, which would break with the standard. + + :param d: Time and date you want to format according to RFC2822. + :type d: datetime.datetime + :returns: The datetime formatted according to the RFC2822. + :rtype: str """ l = locale.setlocale(locale.LC_ALL) locale.setlocale(locale.LC_ALL, 'C') @@ -79,14 +89,34 @@ def formatRFC2822(d): import cgi def htmlencode(s): + """Encode the given string so its content won't be confused as HTML + markup. + + This function exists as a cross-version compatibility alias.""" return cgi.escape(s, quote=True) else: import html def htmlencode(s): + """Encode the given string so its content won't be confused as HTML + markup. + + This function exists as a cross-version compatibility alias.""" return html.escape(s) + def listToHumanreadableStr(l): + """Create a human-readable string out of the given iterable. + + Example:: + + >>> from podgen.util import listToHumanreadableStr + >>> listToHumanreadableStr([1, 2, 3]) + 1, 2 and 3 + + The string ``(empty)`` is returned if the list is empty – it is assumed + that you check whether the list is empty yourself. + """ # TODO: Allow translations of "and" and "empty" length = len(l) l = [str(e) for e in l] From 1bdc5c797ff739c74dfd173c747ed4d5070dcacf Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 00:54:16 +0200 Subject: [PATCH 097/200] Add my name to copyright notices for files that I've contributed to The original copyright notices are of course kept. They don't seem very updated, though.. --- Makefile | 3 +++ doc/conf.py | 2 +- license.bsd | 2 ++ podgen/__init__.py | 11 +++++++++++ podgen/__main__.py | 6 +++--- podgen/category.py | 10 ++++++++++ podgen/episode.py | 7 ++++--- podgen/media.py | 11 +++++++++++ podgen/not_supported_by_itunes_warning.py | 11 +++++++++++ podgen/person.py | 11 +++++++++++ podgen/podcast.py | 3 ++- podgen/tests/test_category.py | 10 ++++++++++ podgen/tests/test_episode.py | 9 ++++++--- podgen/tests/test_media.py | 10 ++++++++++ podgen/tests/test_person.py | 10 ++++++++++ podgen/tests/test_podcast.py | 10 ++++++---- podgen/tests/test_util.py | 10 ++++++++++ podgen/util.py | 3 ++- podgen/version.py | 3 ++- 19 files changed, 125 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index a1d45ba..cf2686d 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +# Modified work Copyright 2016, Thorben Dahl +# See license.* for more details + sdist: doc python setup.py sdist diff --git a/doc/conf.py b/doc/conf.py index 37953c4..dbdafcb 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -45,7 +45,7 @@ # General information about the project. project = u'PodGen' -copyright = u'2014, Lars Kiesow and 2016, Thorben Dahl' +copyright = u'2014, Lars Kiesow. Modified work © 2016, Thorben Dahl' # 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/license.bsd b/license.bsd index a8e1879..f328eee 100644 --- a/license.bsd +++ b/license.bsd @@ -2,6 +2,8 @@ Copyright 2011 Lars Kiesow. All rights reserved. http://www.larskiesow.de +Modified work Copyright 2016 Thorben Dahl. All rights reserved. + Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/podgen/__init__.py b/podgen/__init__.py index 8b0bfe0..4c041e3 100644 --- a/podgen/__init__.py +++ b/podgen/__init__.py @@ -1,4 +1,15 @@ # -*- coding: utf-8 -*- +""" + podgen + ~~~~~~ + + Package which makes it easy to generate podcast RSS using Python. + + See the official documentation at https://podgen.readthedocs.org + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" from .podcast import Podcast from .episode import Episode from .media import Media diff --git a/podgen/__main__.py b/podgen/__main__.py index fce63c0..cc6a948 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- ''' podgen - ~~~~~~~ - - :copyright: 2013, Lars Kiesow + ~~~~~~ + :copyright: 2013, Lars Kiesow and 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. ''' diff --git a/podgen/category.py b/podgen/category.py index 2d18b32..d236c99 100644 --- a/podgen/category.py +++ b/podgen/category.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +""" + podgen.category + ~~~~~~~~~~~~~~~ + + This module contains Category, which represents a single iTunes category. + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" class Category(object): """Immutable class representing an iTunes category. diff --git a/podgen/episode.py b/podgen/episode.py index baeede5..2db4381 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- """ - podgen.entry - ~~~~~~~~~~~~~ + podgen.episode + ~~~~~~~~~~~~~~ - :copyright: 2013, Lars Kiesow + :copyright: 2013, Lars Kiesow and 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. """ diff --git a/podgen/media.py b/podgen/media.py index 558bc2f..4a18bde 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -1,3 +1,14 @@ +# -*- coding: utf-8 -*- +""" + podgen.media + ~~~~~~~~~~~~ + + This file contains the Media class, which represents a pointer to a media + file. + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" import warnings from future.moves.urllib.parse import urlparse import datetime diff --git a/podgen/not_supported_by_itunes_warning.py b/podgen/not_supported_by_itunes_warning.py index 7c5f8ad..76fa011 100644 --- a/podgen/not_supported_by_itunes_warning.py +++ b/podgen/not_supported_by_itunes_warning.py @@ -1,2 +1,13 @@ +# -*- coding: utf-8 -*- +""" + podgen.not_supported_by_itunes_warning + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This file contains the NotSupportedByItunesWarning, which is used when the + library is used in a way that is not compatible with iTunes. + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" class NotSupportedByItunesWarning(UserWarning): pass diff --git a/podgen/person.py b/podgen/person.py index 9100ec6..50929d5 100644 --- a/podgen/person.py +++ b/podgen/person.py @@ -1,3 +1,14 @@ +# -*- coding: utf-8 -*- +""" + podgen.person + ~~~~~~~~~~~~~ + + This file contains the Person class, which is used to represent a person or + an entity. + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" class Person(object): """Data-oriented class representing a single person or entity. diff --git a/podgen/podcast.py b/podgen/podcast.py index 87c1c79..8dc077c 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -3,7 +3,8 @@ podgen.feed ~~~~~~~~~~~~ - :copyright: 2013, Lars Kiesow + :copyright: 2013, Lars Kiesow and 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. diff --git a/podgen/tests/test_category.py b/podgen/tests/test_category.py index 1af4ad6..75476e2 100644 --- a/podgen/tests/test_category.py +++ b/podgen/tests/test_category.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +""" + podgen.tests.test_category + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Module for testing the Category class. + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" import unittest from podgen import Category diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index 7c9082e..538032f 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- - """ -Tests for a basic entry + podgen.tests.test_episode + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + Test the Episode class. -These are test cases for a basic entry. + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. """ import unittest diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index d0f8ce4..5d25801 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +""" + podgen.tests.test_media + ~~~~~~~~~~~~~~~~~~~~~~~ + + Test the Media class, which represents a pointer to a media file. + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" from future.utils import iteritems import unittest import warnings diff --git a/podgen/tests/test_person.py b/podgen/tests/test_person.py index 430203c..7e5d49d 100644 --- a/podgen/tests/test_person.py +++ b/podgen/tests/test_person.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +""" + podgen.tests.test_person + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Test the Person class, which represents a person or an entity. + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" import unittest from podgen import Person diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index a0faf5c..3db631c 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- - """ -Tests for a basic feed + podgen.tests.test_podcast + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + Test the Podcast alone, without any Episode objects. -These are test cases for a basic feed. -A basic feed does not contain entries so far. + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. """ import unittest diff --git a/podgen/tests/test_util.py b/podgen/tests/test_util.py index 61c5562..1c9b4eb 100644 --- a/podgen/tests/test_util.py +++ b/podgen/tests/test_util.py @@ -1,3 +1,13 @@ +# -*- coding: utf-8 -*- +""" + podgen.tests.test_util + ~~~~~~~~~~~~~~~~~~~~~~ + + Test some of the functions found in the util module. + + :copyright: 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" import unittest from podgen import util diff --git a/podgen/util.py b/podgen/util.py index 196eb39..27c29a5 100644 --- a/podgen/util.py +++ b/podgen/util.py @@ -5,7 +5,8 @@ This file contains helper functions for the feed generator module. - :copyright: 2013, Lars Kiesow + :copyright: 2013, Lars Kiesow and 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. """ import sys, locale diff --git a/podgen/version.py b/podgen/version.py index ccacf85..2e88cfe 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -3,7 +3,8 @@ podgen.version ~~~~~~~~~~~~~~~ - :copyright: 2013-2015, Lars Kiesow + :copyright: 2013-2015, Lars Kiesow and 2016, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. From 69a32af559b84dbcd7abcef8721e5689a82f8cd1 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 01:57:24 +0200 Subject: [PATCH 098/200] New release, add release artifacts to gitignore Make a few adjustments to setup.py as well. --- .gitignore | 9 +++++++++ podgen/version.py | 2 +- setup.py | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index ff67717..31b5095 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,14 @@ tmp_Atomfeed.xml tmp_Rssfeed.xml # JetBrains IDE .idea/ + +# Documentation build /doc/_build /docs + +# Distribution and building the package +/dist +/MANIFEST +/build +/podgen.egg-info + diff --git a/podgen/version.py b/podgen/version.py index 2e88cfe..ab2af60 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -11,7 +11,7 @@ """ 'Version of python-podgen represented as tuple' -version = (0, 3, 2) +version = (1, 0, 0, "dev1") 'Version of python-podgen represented as string' diff --git a/setup.py b/setup.py index e6e9815..d72f915 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from distutils.core import setup +from setuptools import setup import podgen.version setup( @@ -12,7 +12,7 @@ author = 'Thorben W. S. Dahl', author_email = 'thorben@sjostrom.no', url = 'http://podgen.readthedocs.io/en/latest/', - keywords = ['feed','RSS','podcast','iTunes'], + keywords = ['feed', 'RSS', 'podcast', 'iTunes', 'generator'], license = 'FreeBSD and LGPLv3+', install_requires = ['lxml', 'dateutils', 'future', 'pytz'], classifiers = [ From 49f7005ba9daca23427bf1af4d5267126bc3cdb3 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 02:15:43 +0200 Subject: [PATCH 099/200] Make conf.py compatible with Sphinx < 1.4 Turns out readthedocs uses Sphinx 1.3.5. Suppressing warnings is therefore not possible. --- doc/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index dbdafcb..632f0ee 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -16,10 +16,10 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = '1.4' +#needs_sphinx = '1.4' # Don't show warnings about the button images not being local -suppress_warnings = ['image.nonlocal_uri'] +#suppress_warnings = ['image.nonlocal_uri'] # requires Sphinx >= 1.4 # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. From 483a8ceaa236d91643113ce0ed6cdd9e1a328e43 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 13:30:00 +0200 Subject: [PATCH 100/200] Use tempfile in test_podcast, closes #23 --- .gitignore | 8 -------- Makefile | 2 -- podgen/tests/test_podcast.py | 33 +++++++++++++++++++++++++++++---- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 31b5095..ef86191 100644 --- a/.gitignore +++ b/.gitignore @@ -3,14 +3,6 @@ venv *.pyo *.swp -podgen/tests/tmp_Atomfeed.xml - -podgen/tests/tmp_Rssfeed.xml - -tmp_Atomfeed.xml - -tmp_Rssfeed.xml -# JetBrains IDE .idea/ # Documentation build diff --git a/Makefile b/Makefile index cf2686d..4bf334f 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,6 @@ 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 @@ -49,4 +48,3 @@ test: podgen.tests.test_person podgen.tests.test_media \ podgen.tests.test_util podgen.tests.test_category python -m podgen rss > /dev/null - @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index 3db631c..7b11d49 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -11,6 +11,8 @@ import unittest from lxml import etree +import tempfile +import os from podgen import Person, Category, Podcast import podgen.version @@ -127,11 +129,34 @@ def test_baseFeed(self): def test_rssFeedFile(self): fg = self.fg - filename = 'tmp_Rssfeed.xml' - fg.rss_file(filename=filename, xml_declaration=False) - with open (filename, "r") as myfile: - rssString=myfile.read().replace('\n', '') + # Keep track of our temporary file and its filename + filename = None + file = None + try: + # Get our temporary file name + file = tempfile.NamedTemporaryFile(delete=False) + filename = file.name + # Close the file; we will just use its name + file.close() + # Write the RSS to the file (overwriting it) + fg.rss_file(filename=filename, xml_declaration=False) + # Read the resulting RSS + with open(filename, "r") as myfile: + rssString=myfile.read().replace('\n', '') + finally: + # We don't need the file any longer, so delete it + if filename: + os.unlink(filename) + elif file: + # Ops, we were interrupted between the first and second stmt + filename = file.name + file.close() + os.unlink(filename) + else: + # We were interrupted between entering the try-block and + # getting the temporary file. Not much we can do. + pass self.checkRssString(rssString) From 113662ddf26d7e4687dd2b206370389791a336b1 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 13:32:14 +0200 Subject: [PATCH 101/200] Don't delete comment about JetBrains from .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ef86191..1ef9b92 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ venv *.pyo *.swp +# JetBrains IDE .idea/ # Documentation build From ee0dab7a5145b29a616a84621f8b53f9252ba19c Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 16:03:31 +0200 Subject: [PATCH 102/200] Add support for one pubsubhubbub hub in Podcast, closes #16 --- podgen/podcast.py | 53 ++++++++++++++++++++++++++++++++++++ podgen/tests/test_podcast.py | 24 +++++++++++++--- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/podgen/podcast.py b/podgen/podcast.py index 8dc077c..322c37f 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -248,6 +248,54 @@ def __init__(self, **kwargs): :RSS: itunes:subtitle """ + self.pubsubhubbub = None + """The URL at which the PubSubHubbub_ hub can be found. + + Podcatchers can tell the hub that they want to be notified when a new + episode is released. This way, they don't need to check for new episodes + every few hours; instead, the episodes arrive at their doorstep as soon + as they're published, through a notification sent by the hub. + + :type: :obj:`str` + :RSS: atom:link with ``rel="hub"`` + + .. note:: + + You only need to worry about this attribute if you've `set up + PubSubHubbub`_ for your podcast. PodGen does NOT include this + functionality for you, and you must notify the hub of new content + yourself. + + .. note:: + + In addition to setting this attribute, you should set the + :attr:`.feed_url` to the canonical URL of this feed. That way, there + is no confusion about which URL should be given to the PubSubHubbub + by the podcatcher when subscribing. + + .. note:: + + On top of all this, you MUST make sure you also use the + `Link header`_ in the HTTP response that is sent with this feed, + duplicating the link to the PubSubHubbub and the feed. Example of + what it might look like: + + .. code-block:: none + + Link: ; rel="hub", + ; rel="self" + + This is necessary for compatibility with the different versions of + PubSubHubbub. The `latest version of the standard`_ specifically says + that publishers MUST use the Link header. + + .. _PubSubHubbub: https://en.wikipedia.org/wiki/PubSubHubbub + .. _set up PubSubHubbub: + https://indieweb.org/How_to_publish_and_consume_PubSubHubbub + .. _Link header: https://tools.ietf.org/html/rfc5988#page-6 + .. _latest version of the standard: http://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html#rfc.section.4 + """ + # Populate the podcast with the keyword arguments for attribute, value in iteritems(kwargs): if hasattr(self, attribute): @@ -523,6 +571,11 @@ def _create_rss(self): link_to_self.attrib['rel'] = 'self' link_to_self.attrib['type'] = 'application/rss+xml' + if self.pubsubhubbub: + link_to_hub = etree.SubElement(channel, '{%s}link' % nsmap['atom']) + link_to_hub.attrib['href'] = self.pubsubhubbub + link_to_hub.attrib['rel'] = 'hub' + for entry in self.episodes: item = entry.rss_entry() channel.append(item) diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index 3db631c..9ef446d 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -44,6 +44,8 @@ def setUp(self): self.cloudRegisterProcedure = 'registerProcedure' self.cloudProtocol = 'SOAP 1.1' + self.pubsubhubbub = "http://pubsubhubbub.example.com/" + self.contributor = {'name':"Contributor Name", 'email': 'Contributor email'} self.copyright = "The copyright notice" @@ -67,6 +69,7 @@ def setUp(self): fg.language = self.language fg.cloud = (self.cloudDomain, self.cloudPort, self.cloudPath, self.cloudRegisterProcedure, self.cloudProtocol) + fg.pubsubhubbub = self.pubsubhubbub fg.copyright = self.copyright fg.authors.append(self.author) fg.skip_days = self.skip_days @@ -89,6 +92,7 @@ def test_constructor(self): language=self.language, cloud=(self.cloudDomain, self.cloudPort, self.cloudPath, self.cloudRegisterProcedure, self.cloudProtocol), + pubsubhubbub=self.pubsubhubbub, copyright=self.copyright, authors=[self.author], skip_days=self.skip_days, @@ -124,6 +128,7 @@ def test_baseFeed(self): assert fg.image == self.image assert fg.owner == self.owner assert fg.complete == self.complete + assert fg.pubsubhubbub == self.pubsubhubbub def test_rssFeedFile(self): fg = self.fg @@ -164,10 +169,21 @@ def checkRssString(self, rssString): assert channel.find("skipDays").find("day").text in self.skip_days assert int(channel.find("skipHours").find("hour").text) in self.skip_hours assert self.web_master.email in channel.find("webMaster").text - assert channel.find("{%s}link" % nsAtom).get('href') == self.feed_url - assert channel.find("{%s}link" % nsAtom).get('rel') == 'self' - assert channel.find("{%s}link" % nsAtom).get('type') == \ - 'application/rss+xml' + + links = channel.findall("{%s}link" % nsAtom) + selflinks = [link for link in links if link.get('rel') == 'self'] + hublinks = [link for link in links if link.get('rel') == 'hub'] + + assert selflinks, "No element found" + selflink = selflinks[0] + assert selflink.get('href') == self.feed_url + assert selflink.get('type') == 'application/rss+xml' + + assert hublinks, "No element found" + hublink = hublinks[0] + assert hublink.get('href') == self.pubsubhubbub + assert hublink.get('type') is None + assert channel.find("{%s}image" % self.nsItunes).get('href') == \ self.image owner = channel.find("{%s}owner" % self.nsItunes) From 9b013ba59ab5e02139e7e1aa67413cfbc66cb416 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 16:28:37 +0200 Subject: [PATCH 103/200] Add methods for fixing the episode's order, closes #20 --- podgen/podcast.py | 27 +++++++++++++++++++++++++++ podgen/tests/test_episode.py | 22 +++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/podgen/podcast.py b/podgen/podcast.py index 8dc077c..a8d0503 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -578,6 +578,33 @@ def rss_file(self, filename, minimize=False, doc.write(filename, pretty_print=not minimize, encoding=encoding, xml_declaration=xml_declaration) + def apply_episode_order(self): + """Make sure that the episodes appear on iTunes in the exact order + they have in :attr:`~.Podcast.episodes`. + + This will set each :attr:`.Episode.position` so it matches the episode's + position in :attr:`.Podcast.episodes`. + + If you're using some :class:`.Episode` objects in multiple podcast + feeds and you don't use this method with every feed, you might want to + call :meth:`.Podcast.clear_episode_order` after generating this feed's + RSS so an episode's position in this feed won't affect its position in + the other feeds. + """ + for i, episode in enumerate(self.episodes): + position = i + 1 + episode.position = position + + def clear_episode_order(self): + """Reset :attr:`.Episode.position` for every single episode. + + Use this if you want to reuse an :class:`.Episode` object in another + feed, and don't want its position in this feed to affect where it + appears in the other feed. This is not needed if you'll call + :meth:`.Podcast.apply_episode_order` on the other feed, though.""" + for episode in self.episodes: + episode.position = None + @property def last_updated(self): """The last time the feed was generated. It defaults to the time and diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index 538032f..705976f 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -353,4 +353,24 @@ def test_summariesHtml(self): assert d is not None assert "A <b>cool</b> summary" in d.text - + def test_applyOrder(self): + # Test that position is set (testing Podcast and Episode) + self.fg.apply_episode_order() + self.assertEqual(self.fg.episodes[0].position, 1) + self.assertEqual(self.fg.episodes[1].position, 2) + self.assertEqual(self.fg.episodes[2].position, 3) + + # Test that position is also output as part of RSS (testing Episode) + itunes_order = self.fe.rss_entry().find("{%s}order" % self.itunes_ns) + assert itunes_order is not None + self.assertEqual(itunes_order.text, str(self.fe.position)) + + # Test that clearing works (testing Podcast and Episode) + self.fg.clear_episode_order() + assert self.fg.episodes[0].position is None + assert self.fg.episodes[1].position is None + assert self.fg.episodes[2].position is None + + # No longer itunes:order element (testing Episode) + itunes_order = self.fe.rss_entry().find("{%s}order" % self.itunes_ns) + assert itunes_order is None From bf7d10790eb3184c7d3820d9240a9f4e7e342cdd Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 17:03:16 +0200 Subject: [PATCH 104/200] Test all attributes of Podcast --- podgen/tests/test_podcast.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index f7dbb76..96b3352 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -37,6 +37,7 @@ def setUp(self): self.website = 'http://example.com' self.description = 'This is a cool feed!' + self.subtitle = 'Coolest of all' self.language = 'en' @@ -63,11 +64,13 @@ def setUp(self): self.image = "http://example.com/static/podcast.png" self.owner = self.author self.complete = True + self.new_feed_url = "https://example.com/feeds/myfeed2.rss" fg.name = self.name fg.website = self.website fg.description = self.description + fg.subtitle = self.subtitle fg.language = self.language fg.cloud = (self.cloudDomain, self.cloudPort, self.cloudPath, self.cloudRegisterProcedure, self.cloudProtocol) @@ -82,6 +85,7 @@ def setUp(self): fg.image = self.image fg.owner = self.owner fg.complete = self.complete + fg.new_feed_url = self.new_feed_url self.fg = fg @@ -91,6 +95,7 @@ def test_constructor(self): name=self.name, website=self.website, description=self.description, + subtitle=self.subtitle, language=self.language, cloud=(self.cloudDomain, self.cloudPort, self.cloudPath, self.cloudRegisterProcedure, self.cloudProtocol), @@ -105,6 +110,7 @@ def test_constructor(self): image=self.image, owner=self.owner, complete=self.complete, + new_feed_url=self.new_feed_url ) # Test that the fields are actually set self.test_baseFeed() @@ -124,6 +130,7 @@ def test_baseFeed(self): assert fg.website == self.website assert fg.description == self.description + assert fg.subtitle == self.subtitle assert fg.language == self.language assert fg.feed_url == self.feed_url @@ -131,6 +138,12 @@ def test_baseFeed(self): assert fg.owner == self.owner assert fg.complete == self.complete assert fg.pubsubhubbub == self.pubsubhubbub + assert fg.cloud == (self.cloudDomain, self.cloudPort, self.cloudPath, + self.cloudRegisterProcedure, self.cloudProtocol) + assert fg.copyright == self.copyright + assert fg.new_feed_url == self.new_feed_url + assert fg.skip_days == self.skip_days + assert fg.skip_hours == self.skip_hours def test_rssFeedFile(self): fg = self.fg @@ -180,7 +193,11 @@ def checkRssString(self, rssString): assert channel.find("title").text == self.name assert channel.find("description").text == self.description + assert channel.find("{%s}subtitle" % self.nsItunes).text == \ + self.subtitle + assert channel.find("link").text == self.website assert channel.find("lastBuildDate").text != None + assert channel.find("language").text == self.language assert channel.find("docs").text == "http://www.rssboard.org/rss-specification" assert self.programname in channel.find("generator").text assert channel.find("cloud").get('domain') == self.cloudDomain @@ -216,6 +233,8 @@ def checkRssString(self, rssString): assert owner.find("{%s}email" % self.nsItunes).text == self.owner.email assert channel.find("{%s}complete" % self.nsItunes).text.lower() == \ "yes" + assert channel.find("{%s}new-feed-url" % self.nsItunes).text == \ + self.new_feed_url def test_feedUrlValidation(self): self.assertRaises(ValueError, setattr, self.fg, "feed_url", From 85d1f2a4fa547eaa8d36e075e93e11118ba28021 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 8 Jul 2016 17:51:47 +0200 Subject: [PATCH 105/200] Test all attributes of Episode --- podgen/episode.py | 5 +- podgen/tests/test_episode.py | 172 +++++++++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 10 deletions(-) diff --git a/podgen/episode.py b/podgen/episode.py index 2db4381..d756564 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -288,11 +288,10 @@ def rss_entry(self): explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) explicit.text = "Yes" if self.__explicit else "No" - if self.is_closed_captioned is not None: + if self.is_closed_captioned: is_closed_captioned = etree.SubElement(entry, '{%s}isClosedCaptioned' % ITUNES_NS) - is_closed_captioned.text = 'Yes' if self.is_closed_captioned \ - else 'No' + is_closed_captioned.text = 'Yes' if self.__position is not None and self.__position >= 0: order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index 705976f..07fba7c 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -320,7 +320,7 @@ def test_summaries(self): self.fe.summary = "A short summary" d = self.fe.rss_entry().find("description") assert d is not None - assert "A short summary" in d.text + assert "A short summary" == d.text ce = self.fe.rss_entry().find(content_encoded) assert ce is None @@ -328,7 +328,7 @@ def test_summaries(self): self.fe.long_summary = "A long summary with more words" d = self.fe.rss_entry().find("description") assert d is not None - assert "A long summary with more words" in d.text + assert "A long summary with more words" == d.text ce = self.fe.rss_entry().find(content_encoded) assert ce is None @@ -337,23 +337,23 @@ def test_summaries(self): self.fe.long_summary = "A long summary with more words" d = self.fe.rss_entry().find("description") assert d is not None - assert "A short summary" in d.text + assert "A short summary" == d.text ce = self.fe.rss_entry().find(content_encoded) assert ce is not None - assert "A long summary with more words" in ce.text + assert "A long summary with more words" == ce.text def test_summariesHtml(self): self.fe.summary = "A cool summary" d = self.fe.rss_entry().find("description") assert d is not None - assert "A cool summary" in d.text + assert "A cool summary" == d.text self.fe.summary = htmlencode("A cool summary") d = self.fe.rss_entry().find("description") assert d is not None - assert "A <b>cool</b> summary" in d.text + assert "A <b>cool</b> summary" == d.text - def test_applyOrder(self): + def test_position(self): # Test that position is set (testing Podcast and Episode) self.fg.apply_episode_order() self.assertEqual(self.fg.episodes[0].position, 1) @@ -374,3 +374,161 @@ def test_applyOrder(self): # No longer itunes:order element (testing Episode) itunes_order = self.fe.rss_entry().find("{%s}order" % self.itunes_ns) assert itunes_order is None + + def test_mandatoryAttributes(self): + ep = Episode() + self.assertRaises((RuntimeError, ValueError), ep.rss_entry) + + ep.title = "A title" + ep.rss_entry() + + ep.title = "" + self.assertRaises((RuntimeError, ValueError), ep.rss_entry) + + ep.title = None + self.assertRaises((RuntimeError, ValueError), ep.rss_entry) + + ep.summary = "A summary" + ep.rss_entry() + + ep.summary = "" + self.assertRaises((RuntimeError, ValueError), ep.rss_entry) + + ep.summary = None + self.assertRaises((RuntimeError, ValueError), ep.rss_entry) + + def test_explicit(self): + # Don't appear if None (use podcast's explicit value) + assert self.fe.explicit is None + assert self.fe.rss_entry().find("{%s}explicit" % self.itunes_ns) is None + + # Appear and say it's explicit if True + self.fe.explicit = True + itunes_explicit = self.fe.rss_entry()\ + .find("{%s}explicit" % self.itunes_ns) + assert itunes_explicit is not None + assert itunes_explicit.text.lower() in ("yes", "explicit", "true") + + # Appear and say it's clean if False + self.fe.explicit = False + itunes_explicit = self.fe.rss_entry()\ + .find("{%s}explicit" % self.itunes_ns) + assert itunes_explicit is not None + assert itunes_explicit.text.lower() in ("no", "clean", "false") + + def test_image(self): + # Test that the attribute works + assert self.fe.image is None + + image = "https://static.example.org/img/hello.png" + self.fe.image = image + self.assertEqual(self.fe.image, image) + + # Test that it appears in XML + itunes_image = self.fe.rss_entry().find("{%s}image" % self.itunes_ns) + assert itunes_image is not None + + # Test that its contents is correct + self.assertEqual(itunes_image.get("href"), image) + assert itunes_image.text is None + + def test_isClosedCaptioned(self): + def get_element(): + return self.fe.rss_entry()\ + .find("{%s}isClosedCaptioned" % self.itunes_ns) + + # Starts out as False or None + assert self.fe.is_closed_captioned is None or \ + self.fe.is_closed_captioned is False + + # Not used when set to False + self.fe.is_closed_captioned = False + self.assertEqual(self.fe.is_closed_captioned, False) + assert get_element() is None + + # Not used when set to None + self.fe.is_closed_captioned = None + assert self.fe.is_closed_captioned is None + assert get_element() is None + + # Used and says "yes" when set to True + self.fe.is_closed_captioned = True + self.assertEqual(self.fe.is_closed_captioned, True) + assert get_element() is not None + self.assertEqual(get_element().text.lower(), "yes") + + def test_link(self): + def get_element(): + return self.fe.rss_entry().find("link") + + # Starts out as None or empty + assert self.fe.link is None or self.fe.link == "" + + # Not used when set to None + self.fe.link = None + assert self.fe.link is None + assert get_element() is None + + # Not used when set to empty + self.fe.link = "" + assert self.fe.link == "" + assert get_element() is None + + # Used when set to something + link = "http://example.com/episode1.html" + self.fe.link = link + self.assertEqual(self.fe.link, link) + assert get_element() is not None + self.assertEqual(get_element().text, link) + + def test_subtitle(self): + def get_element(): + return self.fe.rss_entry().find("{%s}subtitle" % self.itunes_ns) + + # Starts out as None or empty + assert self.fe.subtitle is None or self.fe.subtitle == "" + + # Not used when set to None + self.fe.subtitle = None + assert self.fe.subtitle is None + assert get_element() is None + + # Not used when set to empty + self.fe.subtitle = "" + self.assertEqual(self.fe.subtitle, "") + assert get_element() is None + + # Used when set to something + subtitle = "This is a subtitle" + self.fe.subtitle = subtitle + self.assertEqual(self.fe.subtitle, subtitle) + element = get_element() + assert element is not None + self.assertEqual(element.text, subtitle) + + def test_title(self): + ep = Episode() + + def get_element(): + return ep.rss_entry().find("title") + # Starts out as None or empty. + assert ep.title is None or ep.title == "" + + # We test that you cannot create RSS when it's empty or blank in + # another method. + + # Test that it is set correctly + ep.title = None + assert ep.title is None + + ep.title = "" + self.assertEqual(ep.title, "") + + # Test that the title is used correctly + title = "My Fine Title" + ep.title = title + self.assertEqual(ep.title, title) + + element = get_element() + assert element is not None + self.assertEqual(element.text, title) From 518cb111298a205bace219a8b859a8a66d38c5b2 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 9 Jul 2016 01:37:51 +0200 Subject: [PATCH 106/200] Provide an example on how to extend PodGen Also, make it possible to define new namespaces to be used. --- doc/extending.rst | 179 ++++++++++++++++++++++++++++++++++++++++++++++ doc/index.rst | 1 + podgen/podcast.py | 28 ++++---- 3 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 doc/extending.rst diff --git a/doc/extending.rst b/doc/extending.rst new file mode 100644 index 0000000..d015f6f --- /dev/null +++ b/doc/extending.rst @@ -0,0 +1,179 @@ +Adding new tags +=============== + +Are there XML tags you want to use that aren't supported by PodGen? If so, you +should be able to add them in using inheritance. + +.. note:: + + There hasn't been a focus on making it easy to extend PodGen. + Future versions may provide better support for this. + +.. note:: + + Feel free to add a feature request to GitHub Issues if you think PodGen + should support a certain tag out of the box. + + +Quick How-to +------------ + +#. Create new class that extends Podcast. +#. Add the new attribute. +#. Override :meth:`.Podcast._create_rss`, call super()._create_rss(), + add the new tag to its result and return the new tree. + +If you'll use RSS elements from another namespace, you must make sure you +update the _nsmap attribute of Podcast (you cannot define new namespaces from +an episode!). It is a dictionary with the prefix as key and the +URI for that namespace as value. To use a namespace, you must put the URI inside +curly braces, with the tag name following right after (outside the braces). +For example:: + + "{%s}link" % self._nsmap['atom'] # This will render as atom:link + +The `lxml API documentation`_ is a pain to read, so just look at the source code +for PodGen to figure out how to do things. The example below may help, too. + +.. _lxml API documentation: http://lxml.de/api/index.html + +You can do the same with Episode, if you replace _create_rss() with +rss_entry() above. + +Example: Adding a ttl field +--------------------------- + +The examples here assume version 3 of Python is used. + +Using traditional inheritance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:: + + from lxml import etree + + from podgen import Podcast + + + class PodcastWithTtl(Podcast): + """This is an extension of Podcast, which supports ttl. + + You gain access to ttl by creating a new instance of this class instead + of Podcast. + """ + def __init__(self, *args, **kwargs): + # Initialize the ttl value + self.__ttl = None + + # Has the user passed in ttl value as a keyword? + if 'ttl' in kwargs: + self.ttl = kwargs['ttl'] + kwargs.pop('ttl') # avoid TypeError from super() + + # Call Podcast's constructor + super().__init__(*args, **kwargs) + + @property + def ttl(self): + """Your suggestion for how many minutes podcatchers should wait + before refreshing the feed. + + ttl stands for "time to live". + + :type: :obj:`int` + :RSS: ttl + """ + return self.__ttl + + @ttl.setter + def ttl(self, ttl): + try: + ttl_int = int(ttl) + except ValueError: + raise TypeError("ttl expects an integer, got %s" % ttl) + + if ttl_int < 0: + raise ValueError("Negative ttl values aren't accepted, got %s" + % ttl_int) + self.__ttl = ttl_int + + def _create_rss(self): + rss = super()._create_rss() + channel = rss.find("channel") + if self.__ttl is not None: + ttl = etree.SubElement(channel, 'ttl') + ttl.text = str(self.__ttl) + + return rss + + # How to use the new class (normally, you would put this somewhere else) + myPodcast = PodcastWithTtl(name="Test", website="http://example.org", + explicit=False, description="Testing ttl") + myPodcast.ttl = 90 + print(myPodcast) + + +Using mixins +^^^^^^^^^^^^ + +To use mixins, you cannot make the class with the ttl functionality inherit +Podcast. Instead, it must inherit nothing. Other than that, the code will be +the same, so it doesn't make sense to repeat it here. + +:: + + class TtlMixin(object): + # ... + + # How to use the new mixin + class PodcastWithTtl(TtlMixin, Podcast): + def __init__(*args, **kwargs): + super().__init__(*args, **kwargs) + + myPodcast = PodcastWithTtl(name="Test", website="http://example.org", + explicit=False, description="Testing ttl") + myPodcast.ttl = 90 + print(myPodcast) + +Note the order of the mixins in the class declaration. You should read it as +the path Python takes when looking for a method. First Python checks +PodcastWithTtl, then TtlMixin, finally Podcast. This is also the order the +methods are called when chained together using super(). If you had Podcast +first, Podcast's _create_rss() method would be run first, and since it never +calls super()._create_rss(), the TtlMixin's _create_rss would never be run. +Therefore, you should always have Podcast last in that list. + +Which approach is best? +^^^^^^^^^^^^^^^^^^^^^^^ + +The advantage of mixins isn't really displayed here, but it will become +apparent as you add more and more extensions. Say you define 5 different mixins, +which all add exactly one more attribute to Podcast. If you used traditional +inheritance, you would have to make sure each of those 5 subclasses made up a +tree. That is, class 1 would inherit Podcast. Class 2 would have to inherit +class 1, class 3 would have to inherit class 2 and so on. If two of the classes +had the same superclass, you would be screwed. + +By using mixins, you can put them together however you want. Perhaps for one +podcast you only need ttl, while for another podcast you want to use the +textInput element in addition to ttl, and another podcast requires the +textInput element together with the comments element. Using traditional +inheritance, you would have to duplicate code for textInput in two classes. Not +so with mixins:: + + class PodcastWithTtl(TtlMixin, Podcast): + def __init__(*args, **kwargs): + super().__init__(*args, **kwargs) + + class PodcastWithTtlAndTextInput(TtlMixin, TextInputMixin, Podcast): + def __init__(*args, **kwargs): + super().__init__(*args, **kwargs) + + class PodcastWithTextInputAndComments(TextInputMixin, CommentsMixin, + Podcast): + def __init__(*args, **kwargs): + super().__init__(*args, **kwargs) + +If the list of attributes you want to use varies between different podcasts, +mixins are the way to go. On the other hand, mixins are overkill if you are okay +with one giant class with all the attributes you need. diff --git a/doc/index.rst b/doc/index.rst index 5e01b6b..6984f1e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -57,5 +57,6 @@ User Guide user/basic_usage_guide/part_2 user/basic_usage_guide/part_3 user/example + extending contributing api diff --git a/podgen/podcast.py b/podgen/podcast.py index b80628a..b4e5cbb 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -73,6 +73,16 @@ def __init__(self, **kwargs): self.__episode_class = Episode """The internal value used by self.Episode.""" + self._nsmap = { + 'atom': 'http://www.w3.org/2005/Atom', + 'content': 'http://purl.org/rss/1.0/modules/content/', + 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd', + 'dc': 'http://purl.org/dc/elements/1.1/' + } + """A dictionary which maps namespace prefixes to their namespace URI. + Add a new entry here if you want to use that namespace. + """ + ## RSS # http://www.rssboard.org/rss-specification # Mandatory: @@ -414,17 +424,9 @@ def _create_rss(self): :returns: The root element (ie. the rss element) of the feed. :rtype: lxml.etree.Element """ + ITUNES_NS = self._nsmap['itunes'] - nsmap = { - 'atom': 'http://www.w3.org/2005/Atom', - 'content': 'http://purl.org/rss/1.0/modules/content/', - 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd', - 'dc': 'http://purl.org/dc/elements/1.1/' - } - - ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' - - feed = etree.Element('rss', version='2.0', nsmap=nsmap ) + feed = etree.Element('rss', version='2.0', nsmap=self._nsmap) channel = etree.SubElement(feed, 'channel') if not (self.name and self.website and self.description and self.explicit is not None): @@ -484,7 +486,7 @@ def _create_rss(self): # author without email) for a in self.authors or []: author = etree.SubElement(channel, - '{%s}creator' % nsmap['dc']) + '{%s}creator' % self._nsmap['dc']) if a.name and a.email: author.text = "%s <%s>" % (a.name, a.email) elif a.name: @@ -566,13 +568,13 @@ def _create_rss(self): subtitle.text = self.subtitle if self.feed_url: - link_to_self = etree.SubElement(channel, '{%s}link' % nsmap['atom']) + link_to_self = etree.SubElement(channel, '{%s}link' % self._nsmap['atom']) link_to_self.attrib['href'] = self.feed_url link_to_self.attrib['rel'] = 'self' link_to_self.attrib['type'] = 'application/rss+xml' if self.pubsubhubbub: - link_to_hub = etree.SubElement(channel, '{%s}link' % nsmap['atom']) + link_to_hub = etree.SubElement(channel, '{%s}link' % self._nsmap['atom']) link_to_hub.attrib['href'] = self.pubsubhubbub link_to_hub.attrib['rel'] = 'hub' From 2bebb27175a0549490de3ac2f922964040ddba3f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 12 Jul 2016 13:53:16 +0200 Subject: [PATCH 107/200] Explain the example better, small improvements to extending.rst --- doc/extending.rst | 121 +++++++++++++++++++++++++++++++--------------- 1 file changed, 83 insertions(+), 38 deletions(-) diff --git a/doc/extending.rst b/doc/extending.rst index d015f6f..b8b321a 100644 --- a/doc/extending.rst +++ b/doc/extending.rst @@ -1,8 +1,12 @@ Adding new tags =============== -Are there XML tags you want to use that aren't supported by PodGen? If so, you -should be able to add them in using inheritance. +Are there XML elements you want to use that aren't supported by PodGen? If so, +you should be able to add them in using inheritance. + +.. warning:: + + This is an advanced topic. .. note:: @@ -11,47 +15,67 @@ should be able to add them in using inheritance. .. note:: - Feel free to add a feature request to GitHub Issues if you think PodGen - should support a certain tag out of the box. + Feel free to add a feature request to `GitHub Issues`_ if you think PodGen + should support a certain element out of the box. + +.. _GitHub Issues: https://github.com/tobinus/python-podgen/issues Quick How-to ------------ -#. Create new class that extends Podcast. +#. Create new class that extends :class:`.Podcast`. #. Add the new attribute. -#. Override :meth:`.Podcast._create_rss`, call super()._create_rss(), - add the new tag to its result and return the new tree. +#. Override :meth:`~.Podcast._create_rss`, call ``super()._create_rss()``, + add the new element to its result and return the new tree. + +You can do the same with :class:`.Episode`, if you replace +:meth:`~.Podcast._create_rss` with :meth:`~Episode.rss_entry` above. + +There are plenty of small quirks you have to keep in mind. You are strongly +encouraged to read the example below. + +Using namespaces +^^^^^^^^^^^^^^^^ If you'll use RSS elements from another namespace, you must make sure you -update the _nsmap attribute of Podcast (you cannot define new namespaces from -an episode!). It is a dictionary with the prefix as key and the -URI for that namespace as value. To use a namespace, you must put the URI inside -curly braces, with the tag name following right after (outside the braces). -For example:: +update the :attr:`~.Podcast._nsmap` attribute of :class:`.Podcast` +(you cannot define new namespaces from an episode!). It is a dictionary with the +prefix as key and the URI for that namespace as value. To use a namespace, you +must put the URI inside curly braces, with the tag name following right after +(outside the braces). For example:: "{%s}link" % self._nsmap['atom'] # This will render as atom:link -The `lxml API documentation`_ is a pain to read, so just look at the source code -for PodGen to figure out how to do things. The example below may help, too. +The `lxml API documentation`_ is a pain to read, so just look at the `source code +for PodGen`_ and the example below. .. _lxml API documentation: http://lxml.de/api/index.html +.. _source code for PodGen: https://github.com/tobinus/python-podgen/blob/master/podgen/podcast.py -You can do the same with Episode, if you replace _create_rss() with -rss_entry() above. - -Example: Adding a ttl field ---------------------------- +Example: Adding a ttl element +----------------------------- The examples here assume version 3 of Python is used. +``ttl`` is an RSS element and stands for "time to live", and can only be an +integer which indicates how many minutes the podcatcher can rely on its copy of +the feed before refreshing (or something like that). There is confusion as to +what it is supposed to mean (max refresh frequency? min refresh frequency?), +which is why it is not included in PodGen. If you use it, you should treat it as +the **recommended** update period (source: `RSS Best Practices`_). + +.. _RSS Best Practices: http://www.rssboard.org/rss-profile#element-channel-ttl + Using traditional inheritance ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :: + # The module used to create the XML tree and generate the XML from lxml import etree + # The class we will extend from podgen import Podcast @@ -73,6 +97,9 @@ Using traditional inheritance # Call Podcast's constructor super().__init__(*args, **kwargs) + # If we were to use another namespace, we would add this here: + # self._nsmap['prefix'] = "URI" + @property def ttl(self): """Your suggestion for how many minutes podcatchers should wait @@ -83,42 +110,59 @@ Using traditional inheritance :type: :obj:`int` :RSS: ttl """ + # By using @property and @ttl.setter, we encapsulate the ttl field + # so that we can check the value that is assigned to it. + # If you don't need this, you could just rename self.__ttl to + # self.ttl and remove those two methods. return self.__ttl @ttl.setter def ttl(self, ttl): + # Try to convert to int try: ttl_int = int(ttl) except ValueError: raise TypeError("ttl expects an integer, got %s" % ttl) - + # Is this negative? if ttl_int < 0: raise ValueError("Negative ttl values aren't accepted, got %s" % ttl_int) + # All checks passed self.__ttl = ttl_int def _create_rss(self): + # Let Podcast generate the lxml etree (adding the standard elements) rss = super()._create_rss() + # We must get the channel element, since we want to add subelements + # to it. channel = rss.find("channel") + # Only add the ttl element if it has been populated. if self.__ttl is not None: + # First create our new subelement of channel. ttl = etree.SubElement(channel, 'ttl') + # If we were to use another namespace, we would instead do this: + # ttl = etree.SubElement(channel, + # '{%s}ttl' % self._nsmap['prefix']) + + # Then, fill it with the ttl value ttl.text = str(self.__ttl) + # Return the new etree, now with ttl return rss # How to use the new class (normally, you would put this somewhere else) myPodcast = PodcastWithTtl(name="Test", website="http://example.org", explicit=False, description="Testing ttl") - myPodcast.ttl = 90 + myPodcast.ttl = 90 # or set ttl=90 in the constructor print(myPodcast) Using mixins ^^^^^^^^^^^^ -To use mixins, you cannot make the class with the ttl functionality inherit -Podcast. Instead, it must inherit nothing. Other than that, the code will be -the same, so it doesn't make sense to repeat it here. +To use mixins, you cannot make the class with the ``ttl`` functionality inherit +:class:`.Podcast`. Instead, it must inherit nothing. Other than that, the code +will be the same, so it doesn't make sense to repeat it here. :: @@ -137,28 +181,29 @@ the same, so it doesn't make sense to repeat it here. Note the order of the mixins in the class declaration. You should read it as the path Python takes when looking for a method. First Python checks -PodcastWithTtl, then TtlMixin, finally Podcast. This is also the order the -methods are called when chained together using super(). If you had Podcast -first, Podcast's _create_rss() method would be run first, and since it never -calls super()._create_rss(), the TtlMixin's _create_rss would never be run. -Therefore, you should always have Podcast last in that list. +``PodcastWithTtl``, then ``TtlMixin`` and finally :class:`.Podcast`. This is +also the order the methods are called when chained together using :func:`super`. +If you had Podcast first, :meth:`.Podcast._create_rss` method would be run +first, and since it never calls ``super()._create_rss()``, the ``TtlMixin``'s +``_create_rss`` would never be run. Therefore, you should always have +:class:`.Podcast` last in that list. Which approach is best? ^^^^^^^^^^^^^^^^^^^^^^^ The advantage of mixins isn't really displayed here, but it will become apparent as you add more and more extensions. Say you define 5 different mixins, -which all add exactly one more attribute to Podcast. If you used traditional +which all add exactly one more element to :class:`.Podcast`. If you used traditional inheritance, you would have to make sure each of those 5 subclasses made up a -tree. That is, class 1 would inherit Podcast. Class 2 would have to inherit +tree. That is, class 1 would inherit :class:`.Podcast`. Class 2 would have to inherit class 1, class 3 would have to inherit class 2 and so on. If two of the classes -had the same superclass, you would be screwed. +had the same superclass, you could get screwed. By using mixins, you can put them together however you want. Perhaps for one -podcast you only need ttl, while for another podcast you want to use the -textInput element in addition to ttl, and another podcast requires the -textInput element together with the comments element. Using traditional -inheritance, you would have to duplicate code for textInput in two classes. Not +podcast you only need ``ttl``, while for another podcast you want to use the +``textInput`` element in addition to ``ttl``, and another podcast requires the +``textInput`` element together with the ``comments`` element. Using traditional +inheritance, you would have to duplicate code for ``textInput`` in two classes. Not so with mixins:: class PodcastWithTtl(TtlMixin, Podcast): @@ -174,6 +219,6 @@ so with mixins:: def __init__(*args, **kwargs): super().__init__(*args, **kwargs) -If the list of attributes you want to use varies between different podcasts, +If the list of elements you want to use varies between different podcasts, mixins are the way to go. On the other hand, mixins are overkill if you are okay -with one giant class with all the attributes you need. +with one giant class with all the elements you need. From bec9859d47f510269b413f15be3d26993304dccc Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 12 Jul 2016 15:41:07 +0200 Subject: [PATCH 108/200] New development release: v1.0.0b1 --- podgen/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/podgen/version.py b/podgen/version.py index ab2af60..3b7cbd0 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -11,7 +11,7 @@ """ 'Version of python-podgen represented as tuple' -version = (1, 0, 0, "dev1") +version = (1, 0, "0.b1") 'Version of python-podgen represented as string' From bfa6c2ff6eff6f16b9b04d2e15f44bcf8e54effb Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 12 Jul 2016 23:46:56 +0200 Subject: [PATCH 109/200] Add attribute for XSLT, closes #37 As a consequence, rss_file will now use rss_str internally. This means that the rss result is loaded into memory even when writing to files, which could mean much poorer performance for huge feeds in particular. If this is too much, then we might reverse this commit, but I don't think it will be much of a problem. --- doc/user/basic_usage_guide/part_1.rst | 2 + podgen/__main__.py | 1 + podgen/podcast.py | 98 +++++++++++++++++++++++++-- podgen/tests/test_podcast.py | 55 +++++++++++++-- 4 files changed, 145 insertions(+), 11 deletions(-) diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/user/basic_usage_guide/part_1.rst index 4d52cd8..e53f43f 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/user/basic_usage_guide/part_1.rst @@ -56,6 +56,7 @@ Commonly used p.feed_url = "https://example.com/feeds/podcast.rss" # URL of this feed p.category = Category("Technology", "Podcasting") p.owner = p.authors[0] + p.xslt = "https://example.com/feed/stylesheet.xsl" # URL of XSLT stylesheet Read more: @@ -65,6 +66,7 @@ Read more: * :attr:`~podgen.Podcast.feed_url` * :attr:`~podgen.Podcast.category` * :attr:`~podgen.Podcast.owner` +* :attr:`~podgen.Podcast.xslt` Less commonly used diff --git a/podgen/__main__.py b/podgen/__main__.py index cc6a948..cb5813a 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -57,6 +57,7 @@ def main(): p.complete = False p.new_feed_url = 'http://example.com/new-feed.rss' p.owner = Person('John Doe', 'john@example.com') + p.xslt = "http://example.com/stylesheet.xsl" e1 = p.add_episode() e1.id = 'http://lernfunk.de/_MEDIAID_123#1' diff --git a/podgen/podcast.py b/podgen/podcast.py index b4e5cbb..9fc61e8 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -15,7 +15,8 @@ import dateutil.parser import dateutil.tz from podgen.episode import Episode -from podgen.util import ensure_format, formatRFC2822, listToHumanreadableStr +from podgen.util import ensure_format, formatRFC2822, listToHumanreadableStr, \ + htmlencode from podgen.person import Person import podgen.version import sys @@ -306,6 +307,37 @@ def __init__(self, **kwargs): .. _latest version of the standard: http://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html#rfc.section.4 """ + self.xslt = None + """ + Absolute URL to the XSLT file which web browsers should use with this + feed. + + `XSLT`_ stands for Extensible Stylesheet Language Transformations and + can be regarded as a template language made for transforming XML into + XHTML (among other things). You can use it to avoid giving users an + ugly XML listing when trying to subscribe to your podcast; this + technique is in fact employed by most podcast publishers today. + In a web browser, it looks like a web page, and to the + podcatchers, it looks like a normal podcast feed. To put it another + way, the very same URL can be used as an information web page about the + podcast as well as the URL you subscribe to in podcatchers. + + :type: :obj:`str` + :RSS: Processor instruction right after the xml declaration called + ``xml-stylesheet``, with type set to ``text/xsl`` and href set to + this attribute. + + .. note:: + + Firefox will use its own stylesheet for RSS feeds, so you + must test using another browser and possibly a `simple web server`_ + (``python -m http.server 8000 -b 127.0.0.1``). + + .. _XSLT: https://en.wikipedia.org/wiki/XSLT + .. _simple web server: + https://docs.python.org/3/library/http.server.html + """ + # Populate the podcast with the keyword arguments for attribute, value in iteritems(kwargs): if hasattr(self, attribute): @@ -584,6 +616,34 @@ def _create_rss(self): return feed + def _add_xslt_pi(self, rss, xml_declaration): + """Add an XSLT processor instruction to the RSS string provided.""" + # This is a hackish way of getting a processor instruction between + # the XML declaration and the RSS element; simply because lxml doesn't + # support processor instructions outside the root element. So we do + # a str.replace to replace the first newline with the processor + # instruction, since the XML declaration is followed by a newline. + + # Get the processor instruction as a string + pi = self._get_xslt_pi() + if xml_declaration: + return rss.replace( + "\n", + '\n%s\n' % pi, + 1) + else: + # No declaration, so just put it at the beginning (assuming the + # caller wants it there, why else would you set self.xslt?) + return pi + "\n" + rss + + def _get_xslt_pi(self): + htmlescaped_url = htmlencode(self.xslt) + quote_sanitized = htmlescaped_url.replace('"', '').replace("\\", "") + return etree.tostring(etree.ProcessingInstruction( + "xml-stylesheet", + 'type="text/xsl" href="' + quote_sanitized + '"', + ), encoding=str) + def __str__(self): """Print the podcast in RSS format, using the default options. @@ -607,14 +667,28 @@ def rss_str(self, minimize=False, encoding='UTF-8', :returns: The generated RSS feed as a :obj:`str`. """ feed = self._create_rss() - return etree.tostring(feed, pretty_print=not minimize, encoding=encoding, + rss = etree.tostring(feed, pretty_print=not minimize, encoding=encoding, xml_declaration=xml_declaration).decode(encoding) - + if self.xslt: + return self._add_xslt_pi(rss, xml_declaration=xml_declaration) + else: + return rss def rss_file(self, filename, minimize=False, encoding='UTF-8', xml_declaration=True): """Generate an RSS feed and write the resulting XML to a file. + .. note:: + + If atomicity is needed, then you are expected to provide that + yourself. That means that you should write the feed to a temporary + file which you rename to the final name afterwards; renaming is an + atomic operation on Unix(like) systems. + + .. note:: + + File-like objects given to this method will not be closed. + :param filename: Name of file to write, or a file-like object, or a URL. :type filename: str or fd :param minimize: Set to True to disable splitting the feed into multiple @@ -628,10 +702,20 @@ def rss_file(self, filename, minimize=False, :type xml_declaration: bool :returns: Nothing. """ - feed = self._create_rss() - doc = etree.ElementTree(feed) - doc.write(filename, pretty_print=not minimize, encoding=encoding, - xml_declaration=xml_declaration) + rss = self.rss_str(minimize=minimize, encoding=encoding, + xml_declaration=xml_declaration) + # Have we got a filename, or a file-like object? + if isinstance(filename, string_types): + # It is a string, assume it is filename + with open(filename, "w") as fd: + fd.write(rss) + elif hasattr(filename, "write"): + # It is file-like enough to fool us + filename.write(rss) + else: + raise TypeError("filename must either be a filename (str/unicode) " + "or a file-like object (with write method); " + "%s satisfies none of those conditions." % filename) def apply_episode_order(self): """Make sure that the episodes appear on iTunes in the exact order diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index 96b3352..ffdc38f 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -65,6 +65,7 @@ def setUp(self): self.owner = self.author self.complete = True self.new_feed_url = "https://example.com/feeds/myfeed2.rss" + self.xslt = "http://example.com/feed/stylesheet.xsl" fg.name = self.name @@ -86,6 +87,7 @@ def setUp(self): fg.owner = self.owner fg.complete = self.complete fg.new_feed_url = self.new_feed_url + fg.xslt = self.xslt self.fg = fg @@ -110,7 +112,8 @@ def test_constructor(self): image=self.image, owner=self.owner, complete=self.complete, - new_feed_url=self.new_feed_url + new_feed_url=self.new_feed_url, + xslt=self.xslt, ) # Test that the fields are actually set self.test_baseFeed() @@ -144,10 +147,15 @@ def test_baseFeed(self): assert fg.new_feed_url == self.new_feed_url assert fg.skip_days == self.skip_days assert fg.skip_hours == self.skip_hours + assert fg.xslt == self.xslt def test_rssFeedFile(self): fg = self.fg + rssString = self.getRssFeedFileContents(fg, xml_declaration=False)\ + .replace('\n', '') + self.checkRssString(rssString) + def getRssFeedFileContents(self, fg, **kwargs): # Keep track of our temporary file and its filename filename = None file = None @@ -158,10 +166,10 @@ def test_rssFeedFile(self): # Close the file; we will just use its name file.close() # Write the RSS to the file (overwriting it) - fg.rss_file(filename=filename, xml_declaration=False) + fg.rss_file(filename=filename, **kwargs) # Read the resulting RSS with open(filename, "r") as myfile: - rssString=myfile.read().replace('\n', '') + rssString = myfile.read() finally: # We don't need the file any longer, so delete it if filename: @@ -175,14 +183,19 @@ def test_rssFeedFile(self): # We were interrupted between entering the try-block and # getting the temporary file. Not much we can do. pass + return rssString - self.checkRssString(rssString) def test_rssFeedString(self): fg = self.fg rssString = fg.rss_str(xml_declaration=False) self.checkRssString(rssString) + def test_rssStringAndFileAreEqual(self): + rss_string = self.fg.rss_str() + rss_file = self.getRssFeedFileContents(self.fg) + self.assertEqual(rss_string, rss_file) + def checkRssString(self, rssString): feed = etree.fromstring(rssString) nsRss = self.nsContent @@ -491,5 +504,39 @@ def test_modifyingSkipHoursAfterwards(self): self.fg.skip_hours.remove(26) self.fg.rss_str() # Now it works + # Tests for xslt + def test_xslt_str(self): + def use_str(**kwargs): + return self.fg.rss_str(**kwargs) + self.help_test_xslt_using(use_str) + + def test_xslt_file(self): + def use_file(**kwargs): + return self.getRssFeedFileContents(self.fg, **kwargs) + self.help_test_xslt_using(use_file) + + def help_test_xslt_using(self, generated_feed): + """Run tests for xslt, generating the feed str using the given function. + """ + xslt_path = "http://example.com/mystylesheet.xsl" + xslt_pi = " Date: Wed, 13 Jul 2016 17:26:59 +0200 Subject: [PATCH 110/200] Add methods for notifying hubs, closes #21 Create guide on how to use PubSubHubbub, and reorganize the docs so "Adding new tags" and "Using PubSubHubbub" are placed under "Advanced Topics". --- doc/{ => advanced}/extending.rst | 4 - doc/advanced/index.rst | 8 ++ doc/advanced/pubsubhubbub.rst | 153 +++++++++++++++++++++++++++++++ doc/index.rst | 2 +- podgen/podcast.py | 128 ++++++++++++++++++++++++-- podgen/tests/test_podcast.py | 118 ++++++++++++++++++++++++ 6 files changed, 401 insertions(+), 12 deletions(-) rename doc/{ => advanced}/extending.rst (99%) create mode 100644 doc/advanced/index.rst create mode 100644 doc/advanced/pubsubhubbub.rst diff --git a/doc/extending.rst b/doc/advanced/extending.rst similarity index 99% rename from doc/extending.rst rename to doc/advanced/extending.rst index b8b321a..b844ecb 100644 --- a/doc/extending.rst +++ b/doc/advanced/extending.rst @@ -4,10 +4,6 @@ Adding new tags Are there XML elements you want to use that aren't supported by PodGen? If so, you should be able to add them in using inheritance. -.. warning:: - - This is an advanced topic. - .. note:: There hasn't been a focus on making it easy to extend PodGen. diff --git a/doc/advanced/index.rst b/doc/advanced/index.rst new file mode 100644 index 0000000..b5069e6 --- /dev/null +++ b/doc/advanced/index.rst @@ -0,0 +1,8 @@ +Advanced Topics +=============== + +.. toctree:: + :maxdepth: 1 + + pubsubhubbub + extending diff --git a/doc/advanced/pubsubhubbub.rst b/doc/advanced/pubsubhubbub.rst new file mode 100644 index 0000000..fbe127a --- /dev/null +++ b/doc/advanced/pubsubhubbub.rst @@ -0,0 +1,153 @@ +Using PubSubHubbub +================== + +PubSubHubbub is a free and open protocol for pushing updates to clients +when there's new content available in the feed, as opposed to the traditional +polling clients do. + +Read about `what PubSubHubbub is`_ before you continue. + +.. _what PubSubHubbub is: https://en.wikipedia.org/wiki/PubSubHubbub + +.. note:: + + While the protocol supports having multiple PubSubHubbub hubs for a single + Podcast, there is no support for this in PodGen at the moment. + +-------------------------------------------------------------------------------- + +.. contents:: + :backlinks: none + + +Step 1: Set feed_url +-------------------- + +First, you must ensure that the :class:`.Podcast` object has the +:attr:`~.Podcast.feed_url` attribute set to the URL at which the feed is +accessible. + +:: + + # Assume p is a Podcast object + p.feed_url = "https://example.com/feeds/examplefeed.rss" + +Step 2: Decide on a hub +----------------------- + +The `Wikipedia article`_ mentions a few options you can use (called Community +Hosted hub providers). Alternatively, you can set up and host your own server +using one of the implementations found at the `official PubSubHubbub project +page`_. + +.. _Wikipedia article: https://en.wikipedia.org/wiki/PubSubHubbub#Usage +.. _official PubSubHubbub project page: https://github.com/pubsubhubbub + +Step 3: Set pubsubhubbub +------------------------ + +The Podcast must contain information about which hub to use. You do this by +setting :attr:`~.Podcast.pubsubhubbub` to the URL which the hub is available at. + +:: + + p.pubsubhubbub = "https://pubsubhubbub.example.com/" + +Step 4: Set HTTP Link Header +---------------------------- + +In addition to embedding the PubSubHubbub hub URL and the feed's URL in the +RSS itself, you should use the +`Link header`_ in the HTTP response that is sent with this feed, +duplicating the link to the PubSubHubbub and the feed. Example of +what it might look like: + +.. code-block:: none + + Link: ; rel="hub", + ; rel="self" + +How you can achieve this varies from framework to framework. Here is an example +using Flask (assuming the code is inside a view function):: + + from flask import make_response + from podgen import Podcast + # ... + @app.route("/") # Just as an example + def show_feed(feedname): + p = Podcast() + # ... + # This is the relevant part: + response = make_response(str(p)) + response.headers.add("Link", "<%s>" % p.pubsubhubbub, rel="hub") + response.headers.add("Link", "<%s>" % p.feed_url, rel="self") + return response + +This is necessary for compatibility with the different versions of +PubSubHubbub. The `latest version of the standard`_ specifically says +that publishers MUST use the Link header. If you're unable to do this, you +can try publishing the feed without; most clients and hubs should manage +just fine. + +.. _Link header: https://tools.ietf.org/html/rfc5988#page-6 +.. _latest version of the standard: http://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html#rfc.section.4 + +Step 5: Publish the changes +--------------------------- + +Ensure the changes above are published before proceeding. That is, if a client +downloads the feed, it should receive the Link headers and the pubsubhubbub +and feed_url contents. + +Step 6: Notify the hub of new episodes +-------------------------------------- + +.. note:: + + PodGen does not contain any logic for figuring out whether a Podcast has + changed or not. You must do that part yourself. + +PodGen has two convenience methods that you can use to notify the hub you chose +of any additions made to the feed. The way this works, is that you say to the +hub "Hey, we've made additions to this feed", and the hub looks at the feed and +determines what is new, and sends the new episode(s) to any subscribed clients. + +There are three pre-requisites for using those methods: + +#. The `Requests`_ module has been installed. +#. The :class:`.Podcast` object must have :attr:`~.Podcast.pubsubhubbub` and + :attr:`~.Podcast.feed_url` set. +#. The new episodes will be included in the feed if someone requests the feed + at the moment the methods are called. + + * If this isn't true, the hub will always be lagging one change behind! + +.. _Requests: http://docs.python-requests.org + +One of the methods work best when only one feed has changed. The other one can +handle both the case where one feed has changed, and the case where multiple +feeds have changed. + +.. autosummary:: + podgen.Podcast.notify_hub + podgen.Podcast.notify_multiple + +Example where one Podcast has changed:: + + import requests + from podgen import Podcast + # ... + p.notify_hub(requests) + +Example where multiple Podcasts have changed:: + + import requests + from podgen import Podcast + # ... + changed_podcasts = [ + # ... multiple Podcast objects here + ] + Podcast.notify_multiple(requests, changed_podcasts) + +Always use the latter form when multiple Podcasts have changed; you'll save +lots of time since only one request needs to be made per hub. diff --git a/doc/index.rst b/doc/index.rst index 6984f1e..d4cf6a7 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -57,6 +57,6 @@ User Guide user/basic_usage_guide/part_2 user/basic_usage_guide/part_3 user/example - extending + advanced/index contributing api diff --git a/podgen/podcast.py b/podgen/podcast.py index 9fc61e8..50d7a8b 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -23,6 +23,7 @@ from podgen.compat import string_types import collections import inspect +import warnings _feedgen_version = podgen.version.version_str @@ -272,14 +273,12 @@ def __init__(self, **kwargs): .. note:: - You only need to worry about this attribute if you've `set up - PubSubHubbub`_ for your podcast. PodGen does NOT include this - functionality for you, and you must notify the hub of new content - yourself. + You only need to worry about this attribute if you've :doc:`set up + PubSubHubbub ` for your podcast. .. note:: - In addition to setting this attribute, you should set the + In addition to setting this attribute, you must set the :attr:`.feed_url` to the canonical URL of this feed. That way, there is no confusion about which URL should be given to the PubSubHubbub by the podcatcher when subscribing. @@ -300,9 +299,11 @@ def __init__(self, **kwargs): PubSubHubbub. The `latest version of the standard`_ specifically says that publishers MUST use the Link header. + .. seealso: + The :doc:`guide on how to use PubSubHubbub ` + A more detailed walk-through on how to use PubSubHubbub. + .. _PubSubHubbub: https://en.wikipedia.org/wiki/PubSubHubbub - .. _set up PubSubHubbub: - https://indieweb.org/How_to_publish_and_consume_PubSubHubbub .. _Link header: https://tools.ietf.org/html/rfc5988#page-6 .. _latest version of the standard: http://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html#rfc.section.4 """ @@ -744,6 +745,119 @@ def clear_episode_order(self): for episode in self.episodes: episode.position = None + @classmethod + def notify_multiple(cls, requests, feeds, timeout=10.0): + """Notify the PubSubHubbub hubs of additions in multiple Podcasts. + + When using PubSubHubbub, you must notify the hub whenever a feed has + new entries. Using this method, you can give a list of feeds + which all have new content. This saves time compared to + :meth:`~.Podcast.notify_hub` since they can be made into one request per + hub, instead of having one request per feed per hub. + + The Podcast objects don't need to use the same PubSubHubbub hub. + + :param requests: The requests module, or a Session object. + :type requests: requests or requests.Session + :param feeds: List of Podcast objects which have new or changed content + published. + :type feeds: :obj:`list` of :class:`.Podcast` + :param timeout: Number of seconds we can wait for the server to respond. + Applies to each hub separately. Defaults to ten seconds. + :type timeout: float + :warnings: UserWarning for each feed that has no value for + :attr:`~.Podcast.pubsubhubbub` and :attr:`~.Podcast.feed_url`. + + .. note:: + + This method is blocking, and will return when the servers respond. + + .. note:: + + For this to work for a given feed, it must have + :attr:`~.Podcast.feed_url` and :attr:`~.Podcast.pubsubhubbub` set + correctly. + + .. seealso:: + + The instance method :meth:`~.Podcast.notify_hub` + For notifying a single hub about a single feed + + The :doc:`guide on using PubSubHubbub ` + For a step-for-step guide with examples. + """ + # Which hubs should be notified about what feeds? + hubs = dict() + for feed in feeds: + if feed.pubsubhubbub: + hubs.setdefault(feed.pubsubhubbub, []).append(feed) + else: + warnings.warn("Cannot notify feed %s: pubsubhubbub not set" + % feed) + + # We now have a dictionary which maps a hub to a list of its feeds + for hub, hub_feeds in iteritems(hubs): + # Create the POST parameters + # Tell the PubSubHubbub we are notifying it of updates + params = [("hub.mode", "publish")] + # Tell the hub which feeds have been updated + for feed in hub_feeds: + if feed.feed_url: + params.append(("hub.url", feed.feed_url)) + else: + warnings.warn("Cannot notify feed %s: feed_url not set" + % feed) + # Do the notifying! + if len(params) > 1: + r = requests.post(hub, data=params, timeout=timeout) + r.raise_for_status() + else: + # No feeds which we can notify this hub of... + pass + + def notify_hub(self, requests, timeout=5.0): + """Notify this podcast's PubSubHubbub hub about new episode(s). + + When using PubSubHubbub, you must notify it whenever there's a new + episode available. Use this method to do + so, *after* the new episode is available -- the hub will check the feed + to figure out what's new once you do so. + + :param requests: The requests module, or a Session object. + :type requests: requests or requests.Session + :param timeout: Number of seconds to wait for the hub to respond. + Defaults to five seconds. + :type timeout: float + + .. note:: + + This method is blocking, and will return when the server responds. + + .. note:: + + For this to work, this feed must have + :attr:`~.Podcast.feed_url` and :attr:`~.Podcast.pubsubhubbub` set + correctly. + + .. seealso:: + + The class method :meth:`~.Podcast.notify_multiple` + For sending notifications about multiple feeds. + + The :doc:`guide on using PubSubHubbub ` + For a step-for-step guide with examples. + """ + if not self.feed_url: + raise RuntimeError("Cannot notify hub of this feed, since feed_url " + "is not set.") + elif not self.pubsubhubbub: + raise RuntimeError("Cannot notify hub of this feed, since " + "pubsubhubbub is not set.") + else: + self.notify_multiple(requests, [self], timeout=timeout) + + + @property def last_updated(self): """The last time the feed was generated. It defaults to the time and diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index ffdc38f..f5accb8 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -538,5 +538,123 @@ def help_test_xslt_using(self, generated_feed): assert xslt_path in generated_feed(minimize=True) assert xslt_path in generated_feed(xml_declaration=False) + # Test for notifying a PubSubHubbub + def test_notifyHub(self): + url_hub = self.pubsubhubbub + url_feed = self.feed_url + assertEqual = self.assertEqual + wanted_timeout = None + + class MyLittleRequests(object): + @staticmethod + def post(url, data, timeout, *args, **kwargs): + assertEqual(url_hub, url) + if wanted_timeout: + # Test that the timeout is equal what we put in + assertEqual(wanted_timeout, timeout) + else: + # Test that the default timeout is OK + assert 2.0 < timeout < 10.0, "Too long or short timeout!" + + if isinstance(data, dict): + assert "hub.mode" in data + assertEqual("publish", data['hub.mode']) + + assert "hub.url" in data + assertEqual(url_feed, data['hub.url']) + + else: + assert ("hub.mode", "publish") in data, \ + "Pair (hub.mode, publish) not in %s" % data + assert ("hub.url", url_feed) in data, \ + "Pair (hub.url, %s) not in %s" % (url_feed, data) + + class MyLittleResponse(object): + def raise_for_status(self): + pass + return MyLittleResponse() + + self.fg.notify_hub(MyLittleRequests) + # Test that the timeout parameter is given to requests + wanted_timeout = 50.0 + self.fg.notify_hub(MyLittleRequests, wanted_timeout) + + def test_notifyHubMultiple(self): + url_hub = self.pubsubhubbub + url_other_hub = "https://pubsubhubbub.example.org" + feeds = [Podcast( + name="Test1", + description="Testing a podcast", + website="http://example.com", + explicit=False, + feed_url="http://example.com/feed1.rss", + pubsubhubbub=self.pubsubhubbub), + Podcast( + name="Test2", + description="Testing another podcast", + website="http://example.com", + explicit=True, + feed_url="http://example.com/feed2.rss", + pubsubhubbub=self.pubsubhubbub), + self.fg + ] + + assertEqual = self.assertEqual + wanted_timeout = None + + class MyLittleRequests(object): + num_feeds = 0 + num_requests = 0 + + @staticmethod + def post(url, data, timeout, *args, **kwargs): + assert url in (url_hub, url_other_hub) + + if wanted_timeout: + # Test that the timeout is equal what we put in + assertEqual(wanted_timeout, timeout) + else: + # Test that the default timeout is OK + assert 5.0 < timeout < 30.0, "Too long or short timeout!" + + assert ("hub.mode", "publish") in data, \ + "Pair (hub.mode, publish) not in %s" % data + for url_feed in (podcast.feed_url for podcast in feeds + if podcast.pubsubhubbub == url): + assert ("hub.url", url_feed) in data, \ + "Pair (hub.url, %s) not in %s" % (url_feed, data) + MyLittleRequests.num_feeds += 1 + + class MyLittleResponse(object): + def raise_for_status(self): + pass + MyLittleRequests.num_requests += 1 + return MyLittleResponse() + + Podcast.notify_multiple(MyLittleRequests, feeds) + self.assertEqual(MyLittleRequests.num_feeds, 3) + self.assertEqual(MyLittleRequests.num_requests, 1) + + # Test with timeout + wanted_timeout = 50.0 + Podcast.notify_multiple(MyLittleRequests, feeds, timeout=wanted_timeout) + self.assertEqual(MyLittleRequests.num_feeds, 6) + self.assertEqual(MyLittleRequests.num_requests, 2) + + # Test with one feed with another pubsubhubbub + wanted_timeout = None + feeds.append(Podcast( + name="Test4", + description="Testing another pubsubhubbub", + website="http://example.com", + explicit=True, + feed_url="http://example.com/feed4.rss", + pubsubhubbub=url_other_hub + )) + Podcast.notify_multiple(MyLittleRequests, feeds) + self.assertEqual(MyLittleRequests.num_feeds, 10) + self.assertEqual(MyLittleRequests.num_requests, 4) + + if __name__ == '__main__': unittest.main() From 78c95476075bd54151e2340fefda60a76cc2d668 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 13 Jul 2016 22:37:27 +0200 Subject: [PATCH 111/200] Add support for Python 2.7, closes #19 I'll admit some of the code has been made uglier, but 2.7 is still used widely and so I think it's worth it. However, I have no experience with the unicode/byte/str thing, so surely something has gone horribly wrong in a subtle way somewhere :P --- .travis.yml | 2 +- doc/index.rst | 2 ++ doc/user/installation.rst | 3 +++ podgen/media.py | 11 ++++++----- podgen/podcast.py | 8 ++++---- podgen/tests/test_media.py | 14 +++++++------- podgen/tests/test_podcast.py | 13 +++++++------ readme.md | 2 +- setup.py | 4 +++- 9 files changed, 34 insertions(+), 25 deletions(-) diff --git a/.travis.yml b/.travis.yml index c010786..84f775a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: python python: # - "2.6" -# - "2.7" + - "2.7" - "3.3" - "3.4" - "3.5" diff --git a/doc/index.rst b/doc/index.rst index d4cf6a7..3e70bcd 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -43,6 +43,8 @@ head around ambiguous, undocumented APIs. PodGen incorporates the industry's best practices and lets you focus on collecting the necessary metadata and publishing the podcast. +PodGen is compatible with Python 2.7 and 3.3+. + User Guide ---------- diff --git a/doc/user/installation.rst b/doc/user/installation.rst index b91b64c..87f1288 100644 --- a/doc/user/installation.rst +++ b/doc/user/installation.rst @@ -2,6 +2,9 @@ Installation ============ +PodGen can be used on any system (if not: file a bug report!), and supports +Python 2.7 and 3.3, 3.4 and 3.5. + Use `pip `_:: $ pip install podgen diff --git a/podgen/media.py b/podgen/media.py index 4a18bde..0fe6478 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -11,6 +11,7 @@ """ import warnings from future.moves.urllib.parse import urlparse +from future.utils import raise_from import datetime from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning @@ -246,11 +247,11 @@ def get_type(self, url): try: return self.file_types[file_extension] except KeyError as e: - raise ValueError("The file extension %s was not recognized, which " - "means it's not supported by iTunes. If this is " - "intended, please provide the type yourself so " - "clients can see what type of file it is." - % file_extension) from e + raise_from(ValueError( + "The file extension %s was not recognized, which means it's " + "not supported by iTunes. If this is intended, please provide " + "the type yourself so clients can see what type of file it is." + % file_extension), e) @property def duration(self): diff --git a/podgen/podcast.py b/podgen/podcast.py index 50d7a8b..c49c175 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -643,7 +643,7 @@ def _get_xslt_pi(self): return etree.tostring(etree.ProcessingInstruction( "xml-stylesheet", 'type="text/xsl" href="' + quote_sanitized + '"', - ), encoding=str) + ), encoding="UTF-8").decode("UTF-8") def __str__(self): """Print the podcast in RSS format, using the default options. @@ -660,12 +660,12 @@ def rss_str(self, minimize=False, encoding='UTF-8', lines and adding properly indentation, saving bytes at the cost of readability (default: False). :type minimize: bool - :param encoding: Encoding used in the XML file (default: UTF-8). + :param encoding: Encoding used in the XML declaration (default: UTF-8). :type encoding: str :param xml_declaration: Whether an XML declaration should be added to the output (default: True). :type xml_declaration: bool - :returns: The generated RSS feed as a :obj:`str`. + :returns: The generated RSS feed as a :obj:`str` (unicode in 2.7) """ feed = self._create_rss() rss = etree.tostring(feed, pretty_print=not minimize, encoding=encoding, @@ -1133,7 +1133,7 @@ def skip_days(self, days): if not d.lower() in ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']: raise ValueError('Invalid day %s' % d) - self.__skip_days = {day.capitalize() for day in days} + self.__skip_days = set(day.capitalize() for day in days) else: self.__skip_days = None diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index 5d25801..aa1cda0 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -112,13 +112,13 @@ def test_autoRecognizeType(self): # Mapping between url file extension and type given by iTunes # https://help.apple.com/itc/podcasts_connect/#/itcb54353390 types = { - '.mp3': {"audio/mpeg"}, - '.m4a': {"audio/x-m4a"}, - '.mov': {"video/quicktime"}, - '.mp4': {"video/mp4"}, - '.m4v': {"video/x-m4v"}, - '.pdf': {"application/pdf"}, - '.epub': {"document/x-epub"}, + '.mp3': set(["audio/mpeg"]), + '.m4a': set(["audio/x-m4a"]), + '.mov': set(["video/quicktime"]), + '.mp4': set(["video/mp4"]), + '.m4v': set(["video/x-m4v"]), + '.pdf': set(["application/pdf"]), + '.epub': set(["document/x-epub"]), } for (file_extension, allowed_types) in iteritems(types): diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index f5accb8..4a42911 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -13,6 +13,7 @@ from lxml import etree import tempfile import os +from future.utils import raise_from from podgen import Person, Category, Podcast import podgen.version @@ -53,8 +54,8 @@ def setUp(self): 'email': 'Contributor email'} self.copyright = "The copyright notice" self.docs = 'http://www.rssboard.org/rss-specification' - self.skip_days = {'Tuesday'} - self.skip_hours = {23} + self.skip_days = set(['Tuesday']) + self.skip_hours = set([23]) self.explicit = False @@ -454,12 +455,12 @@ def test_mandatoryValues(self): # Try to create a Podcast once for each mandatory property. # On each iteration, exactly one of the properties is not set. # Therefore, an exception should be thrown on each iteration. - mandatory_properties = { + mandatory_properties = set([ "description", "title", "link", "explicit", - } + ]) for test_property in mandatory_properties: fg = Podcast() @@ -474,8 +475,8 @@ def test_mandatoryValues(self): try: self.assertRaises(ValueError, fg._create_rss) except AssertionError as e: - raise AssertionError("The test failed for %s" % test_property)\ - from e + raise_from(AssertionError( + "The test failed for %s" % test_property), e) def test_withholdFromItunesOffByDefault(self): assert not self.fg.withhold_from_itunes diff --git a/readme.md b/readme.md index d3d14ad..9932eec 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ PodGen (forked from python-feedgen) This module can be used to generate podcast feeds in RSS format, and is -compatible with Python 3.3+. +compatible with Python 2.7 and 3.3+. 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 diff --git a/setup.py b/setup.py index d72f915..23221a4 100755 --- a/setup.py +++ b/setup.py @@ -25,7 +25,9 @@ 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', From a59778facf54a46817c8e17f56e2522a5bbcef77 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 13 Jul 2016 23:02:47 +0200 Subject: [PATCH 112/200] Release v1.0.0b2 --- podgen/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/podgen/version.py b/podgen/version.py index 3b7cbd0..388bc03 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -11,7 +11,7 @@ """ 'Version of python-podgen represented as tuple' -version = (1, 0, "0.b1") +version = (1, 0, "0b2") 'Version of python-podgen represented as string' From b14ea878badb4fbbeb0ef072342993afe5352fbd Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 13 Jul 2016 23:44:20 +0200 Subject: [PATCH 113/200] Update the favicon so it matches the colors Now, it matches the colors of the documentation! --- doc/_static/favicon.ico | Bin 1150 -> 1150 bytes doc/_static/favicon.xcf | Bin 2508 -> 2288 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/doc/_static/favicon.ico b/doc/_static/favicon.ico index 3a79916b91fb13023348746fbe8b85cf6e578ad5..3a795ba77aa29e2d5983b55fcaee984d927b15e8 100644 GIT binary patch literal 1150 zcmbW0%MHRn3`8A?0&?O295_-MWuz|}K=eWhXz>Id8q6jKf}Gi%kG(|Nh#`OD82LWN zc@wdXh*MTl$-MZ4#pnKLQ&ziNZ%tTBJhj^&&?hw{tBh%Q=A3vh>p#zBFAsB^V=b*W z1KnrHD;~dV_$%VAx$0ZmE6@4qYZ@QN92& z25c7OExi;WVO#_iKr5ijxy-mrh+oD$5n+s)ALb(-<&e0i=B)jQ2j*Pqj=+KpCf{=$s~9j6G3 zxoaM&E2pGdzyE82saw5`T0L$@{-Uh~Eymq+BW z5qW$>o*0p*Aah+iN)T9&!?uT;spL*Omo09@I?qnpoLAJZ%di6UU@n_M{@XahZw(@4i%2D^v_LR}QFA)P)!^WT=LzXQ26XXcdoPCq;e=Tmxz-pUozp3-U$naM|dunWz>=zEMkMMx4|UH!aG( z#;4fivBV;W2s7}b81|TQ|2YyDd6St5ePbJoOyW{|9L*u!( z64@Uk&ar%gY(IW$CgHh{w2?27&sMf@pMrmi?j@8xIGmoG3@0;uQ*!Ug>2NR`9`(N; z&z|?6e)aV?%G~qN@yQ?=9wevZBgH^F+drF~9}oLy)8p~cgYNz)Ih-C(Py72`9h@f5 z2K|R$JoL{7Wyk-m1@G^|+tS!XDqiZ%qW&ePLei&pJw4Mr=w=@)G9=5{RjLH>OU zqmH>KMB0e67UH77+ht^dy^G8uiQabY%9I}fUeuDUFGM)O1=`W_gnEqn=LtIRM=rT6?i_Kz*UixcH=>X6ou2WL< zCna1+%{J+fTCKPgmPM6&MB8$SosOX)AAKg6Z2DMOvq%=ub+xkeY4MA+=g*DJ+XB_lUOTY+-o3r5()6daI>jN`G>E zf7P~?6$M#ByWxgbK}Bnowd`6Rht}GyG(Gw_sRq5MO&_60d6n&1P|;fz+LgywT+x3m z9r7NSBhO)h7Jsboq{Y4u>yX{1Rp;9m=Skv!74MQ&V(TVWt+6ir1&p3GR)v4mp(Ymo zDW6-LaVs)zMaHejxD|-YxE1I!)2+zNGKpS3@y3)604=gQCB-K$C7f6Vhg2m32SKSD zEwjk7h{f~EMORsbmqp^A(AOrHv+LCyq- z%b5UO=9(Zk%O!f5pfRNbK#ROiNil(xaN->-Q@;TTGaM%Q-bS~Vark|i-f`ZcuCMcKyaRn2g%fgtTFe@Z_nV>PH13-(S zPDwF=lyG7uJXDnw93-c1x-3#FQWnoqmtGYqUXhB!LSLI)&c`axB?2uxcS&BI zw~4$>FQj+Q1>$KM=Pz;R$2!!+c7BpSclJB7K&tmPue#OE9JO1O^J;FtO6}I6F1Gy* DdrAVX From e7fd5686a85caff3686a4fbbcefea676443032ad Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 14 Jul 2016 13:06:38 +0200 Subject: [PATCH 114/200] Remove notify* methods, rewrite parts of the guide The methods for notifying hubs only work with one type of hubs, and I don't want to make a decision on behalf of the users as to which hub they'll use (especially since the open source alternative, Switchboard, uses some other tokens than the popular Google Appspot and Superfeedr hub). Instead, I want the user to do that job themselves - it is not that hard. Update the guide to reflect that this is no longer implemented in PodGen, and give a few pointers on how to implement it. Warn that if you add the atom:link rel="hub" element, but don't notify the hub of new updates, your listeners will miss out on any new episodes. --- doc/advanced/pubsubhubbub.rst | 92 +++++++++------------ podgen/podcast.py | 150 ++-------------------------------- podgen/tests/test_podcast.py | 117 -------------------------- 3 files changed, 47 insertions(+), 312 deletions(-) diff --git a/doc/advanced/pubsubhubbub.rst b/doc/advanced/pubsubhubbub.rst index fbe127a..40a73f0 100644 --- a/doc/advanced/pubsubhubbub.rst +++ b/doc/advanced/pubsubhubbub.rst @@ -14,6 +14,12 @@ Read about `what PubSubHubbub is`_ before you continue. While the protocol supports having multiple PubSubHubbub hubs for a single Podcast, there is no support for this in PodGen at the moment. +.. warning:: + + Read through the whole guide at least once before you start implementing + this. Specifically, you must *not* set the :attr:`~.Podcast.pubsubhubbub` + attribute if you haven't got a way to notify hubs of new episodes. + -------------------------------------------------------------------------------- .. contents:: @@ -37,11 +43,10 @@ Step 2: Decide on a hub The `Wikipedia article`_ mentions a few options you can use (called Community Hosted hub providers). Alternatively, you can set up and host your own server -using one of the implementations found at the `official PubSubHubbub project -page`_. +using one of the open source alternatives, like for instance `Switchboard`_. .. _Wikipedia article: https://en.wikipedia.org/wiki/PubSubHubbub#Usage -.. _official PubSubHubbub project page: https://github.com/pubsubhubbub +.. _Switchboard: https://github.com/aaronpk/Switchboard Step 3: Set pubsubhubbub ------------------------ @@ -68,7 +73,7 @@ what it might look like: ; rel="self" How you can achieve this varies from framework to framework. Here is an example -using Flask (assuming the code is inside a view function):: +using `Flask`_ (assuming the code is inside a view function):: from flask import make_response from podgen import Podcast @@ -91,63 +96,46 @@ just fine. .. _Link header: https://tools.ietf.org/html/rfc5988#page-6 .. _latest version of the standard: http://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html#rfc.section.4 +.. _Flask: http://flask.pocoo.org/ -Step 5: Publish the changes ---------------------------- - -Ensure the changes above are published before proceeding. That is, if a client -downloads the feed, it should receive the Link headers and the pubsubhubbub -and feed_url contents. - -Step 6: Notify the hub of new episodes +Step 5: Notify the hub of new episodes -------------------------------------- -.. note:: +.. warning:: - PodGen does not contain any logic for figuring out whether a Podcast has - changed or not. You must do that part yourself. + The hub won't know that you've published new episodes unless you tell it about + it. If you don't do this, the hub will assume there is no new content, and + clients which trust the hub to inform them of new episodes will think there + is no new content either. **Don't set the pubsubhubbub field if you haven't set + this up yet.** -PodGen has two convenience methods that you can use to notify the hub you chose -of any additions made to the feed. The way this works, is that you say to the -hub "Hey, we've made additions to this feed", and the hub looks at the feed and -determines what is new, and sends the new episode(s) to any subscribed clients. +Different hubs have different ways of notifying it of new episodes. That's why +you must notify the hubs yourself; supporting all hubs is out of scope for +PodGen. -There are three pre-requisites for using those methods: - -#. The `Requests`_ module has been installed. -#. The :class:`.Podcast` object must have :attr:`~.Podcast.pubsubhubbub` and - :attr:`~.Podcast.feed_url` set. -#. The new episodes will be included in the feed if someone requests the feed - at the moment the methods are called. - - * If this isn't true, the hub will always be lagging one change behind! - -.. _Requests: http://docs.python-requests.org +If you use the `Google PubSubHubbub`_ or the `Superfeedr hub`_, there is a +pip package called `PubSubHubbub_Publisher`_ which provides this functionality +for you. Example:: -One of the methods work best when only one feed has changed. The other one can -handle both the case where one feed has changed, and the case where multiple -feeds have changed. - -.. autosummary:: - podgen.Podcast.notify_hub - podgen.Podcast.notify_multiple - -Example where one Podcast has changed:: - - import requests + from pubsubhubbub_publish import publish, PublishError from podgen import Podcast # ... - p.notify_hub(requests) + try: + publish(p.pubsubhubbub, p.feed_url) + except PublishError as e: + # Handle error -Example where multiple Podcasts have changed:: +In all other cases, you're encouraged to use `Requests`_ to make the necessary +`POST request`_ (if no publisher package is available). - import requests - from podgen import Podcast - # ... - changed_podcasts = [ - # ... multiple Podcast objects here - ] - Podcast.notify_multiple(requests, changed_podcasts) +.. note:: -Always use the latter form when multiple Podcasts have changed; you'll save -lots of time since only one request needs to be made per hub. + If you have changes in multiple feeds, you can usually send just one single + notification to the hub with all the feeds' URLs included. It is worth + researching, as it can save both you and the hub a lot of time. + +.. _Google PubSubHubbub: https://pubsubhubbub.appspot.com/ +.. _Superfeedr hub: https://pubsubhubbub.superfeedr.com/ +.. _PubSubHubbub_Publisher: https://pypi.python.org/pypi/PubSubHubbub_Publisher +.. _Requests: http://docs.python-requests.org +.. _POST request: http://docs.python-requests.org/en/master/user/quickstart/#make-a-request diff --git a/podgen/podcast.py b/podgen/podcast.py index c49c175..9340bd7 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -271,41 +271,18 @@ def __init__(self, **kwargs): :type: :obj:`str` :RSS: atom:link with ``rel="hub"`` - .. note:: - - You only need to worry about this attribute if you've :doc:`set up - PubSubHubbub ` for your podcast. - - .. note:: - - In addition to setting this attribute, you must set the - :attr:`.feed_url` to the canonical URL of this feed. That way, there - is no confusion about which URL should be given to the PubSubHubbub - by the podcatcher when subscribing. - - .. note:: - - On top of all this, you MUST make sure you also use the - `Link header`_ in the HTTP response that is sent with this feed, - duplicating the link to the PubSubHubbub and the feed. Example of - what it might look like: - - .. code-block:: none - - Link: ; rel="hub", - ; rel="self" + .. warning:: - This is necessary for compatibility with the different versions of - PubSubHubbub. The `latest version of the standard`_ specifically says - that publishers MUST use the Link header. + Do NOT set this attribute if you haven't set up mechanics for + notifying the hub of new episodes. Doing so could make it appear to + your listeners like there is no new content for this feed. See the + guide. - .. seealso: + .. seealso:: The :doc:`guide on how to use PubSubHubbub ` - A more detailed walk-through on how to use PubSubHubbub. + A step-for-step guide with examples. .. _PubSubHubbub: https://en.wikipedia.org/wiki/PubSubHubbub - .. _Link header: https://tools.ietf.org/html/rfc5988#page-6 - .. _latest version of the standard: http://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html#rfc.section.4 """ self.xslt = None @@ -745,119 +722,6 @@ def clear_episode_order(self): for episode in self.episodes: episode.position = None - @classmethod - def notify_multiple(cls, requests, feeds, timeout=10.0): - """Notify the PubSubHubbub hubs of additions in multiple Podcasts. - - When using PubSubHubbub, you must notify the hub whenever a feed has - new entries. Using this method, you can give a list of feeds - which all have new content. This saves time compared to - :meth:`~.Podcast.notify_hub` since they can be made into one request per - hub, instead of having one request per feed per hub. - - The Podcast objects don't need to use the same PubSubHubbub hub. - - :param requests: The requests module, or a Session object. - :type requests: requests or requests.Session - :param feeds: List of Podcast objects which have new or changed content - published. - :type feeds: :obj:`list` of :class:`.Podcast` - :param timeout: Number of seconds we can wait for the server to respond. - Applies to each hub separately. Defaults to ten seconds. - :type timeout: float - :warnings: UserWarning for each feed that has no value for - :attr:`~.Podcast.pubsubhubbub` and :attr:`~.Podcast.feed_url`. - - .. note:: - - This method is blocking, and will return when the servers respond. - - .. note:: - - For this to work for a given feed, it must have - :attr:`~.Podcast.feed_url` and :attr:`~.Podcast.pubsubhubbub` set - correctly. - - .. seealso:: - - The instance method :meth:`~.Podcast.notify_hub` - For notifying a single hub about a single feed - - The :doc:`guide on using PubSubHubbub ` - For a step-for-step guide with examples. - """ - # Which hubs should be notified about what feeds? - hubs = dict() - for feed in feeds: - if feed.pubsubhubbub: - hubs.setdefault(feed.pubsubhubbub, []).append(feed) - else: - warnings.warn("Cannot notify feed %s: pubsubhubbub not set" - % feed) - - # We now have a dictionary which maps a hub to a list of its feeds - for hub, hub_feeds in iteritems(hubs): - # Create the POST parameters - # Tell the PubSubHubbub we are notifying it of updates - params = [("hub.mode", "publish")] - # Tell the hub which feeds have been updated - for feed in hub_feeds: - if feed.feed_url: - params.append(("hub.url", feed.feed_url)) - else: - warnings.warn("Cannot notify feed %s: feed_url not set" - % feed) - # Do the notifying! - if len(params) > 1: - r = requests.post(hub, data=params, timeout=timeout) - r.raise_for_status() - else: - # No feeds which we can notify this hub of... - pass - - def notify_hub(self, requests, timeout=5.0): - """Notify this podcast's PubSubHubbub hub about new episode(s). - - When using PubSubHubbub, you must notify it whenever there's a new - episode available. Use this method to do - so, *after* the new episode is available -- the hub will check the feed - to figure out what's new once you do so. - - :param requests: The requests module, or a Session object. - :type requests: requests or requests.Session - :param timeout: Number of seconds to wait for the hub to respond. - Defaults to five seconds. - :type timeout: float - - .. note:: - - This method is blocking, and will return when the server responds. - - .. note:: - - For this to work, this feed must have - :attr:`~.Podcast.feed_url` and :attr:`~.Podcast.pubsubhubbub` set - correctly. - - .. seealso:: - - The class method :meth:`~.Podcast.notify_multiple` - For sending notifications about multiple feeds. - - The :doc:`guide on using PubSubHubbub ` - For a step-for-step guide with examples. - """ - if not self.feed_url: - raise RuntimeError("Cannot notify hub of this feed, since feed_url " - "is not set.") - elif not self.pubsubhubbub: - raise RuntimeError("Cannot notify hub of this feed, since " - "pubsubhubbub is not set.") - else: - self.notify_multiple(requests, [self], timeout=timeout) - - - @property def last_updated(self): """The last time the feed was generated. It defaults to the time and diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index 4a42911..6c97555 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -539,123 +539,6 @@ def help_test_xslt_using(self, generated_feed): assert xslt_path in generated_feed(minimize=True) assert xslt_path in generated_feed(xml_declaration=False) - # Test for notifying a PubSubHubbub - def test_notifyHub(self): - url_hub = self.pubsubhubbub - url_feed = self.feed_url - assertEqual = self.assertEqual - wanted_timeout = None - - class MyLittleRequests(object): - @staticmethod - def post(url, data, timeout, *args, **kwargs): - assertEqual(url_hub, url) - if wanted_timeout: - # Test that the timeout is equal what we put in - assertEqual(wanted_timeout, timeout) - else: - # Test that the default timeout is OK - assert 2.0 < timeout < 10.0, "Too long or short timeout!" - - if isinstance(data, dict): - assert "hub.mode" in data - assertEqual("publish", data['hub.mode']) - - assert "hub.url" in data - assertEqual(url_feed, data['hub.url']) - - else: - assert ("hub.mode", "publish") in data, \ - "Pair (hub.mode, publish) not in %s" % data - assert ("hub.url", url_feed) in data, \ - "Pair (hub.url, %s) not in %s" % (url_feed, data) - - class MyLittleResponse(object): - def raise_for_status(self): - pass - return MyLittleResponse() - - self.fg.notify_hub(MyLittleRequests) - # Test that the timeout parameter is given to requests - wanted_timeout = 50.0 - self.fg.notify_hub(MyLittleRequests, wanted_timeout) - - def test_notifyHubMultiple(self): - url_hub = self.pubsubhubbub - url_other_hub = "https://pubsubhubbub.example.org" - feeds = [Podcast( - name="Test1", - description="Testing a podcast", - website="http://example.com", - explicit=False, - feed_url="http://example.com/feed1.rss", - pubsubhubbub=self.pubsubhubbub), - Podcast( - name="Test2", - description="Testing another podcast", - website="http://example.com", - explicit=True, - feed_url="http://example.com/feed2.rss", - pubsubhubbub=self.pubsubhubbub), - self.fg - ] - - assertEqual = self.assertEqual - wanted_timeout = None - - class MyLittleRequests(object): - num_feeds = 0 - num_requests = 0 - - @staticmethod - def post(url, data, timeout, *args, **kwargs): - assert url in (url_hub, url_other_hub) - - if wanted_timeout: - # Test that the timeout is equal what we put in - assertEqual(wanted_timeout, timeout) - else: - # Test that the default timeout is OK - assert 5.0 < timeout < 30.0, "Too long or short timeout!" - - assert ("hub.mode", "publish") in data, \ - "Pair (hub.mode, publish) not in %s" % data - for url_feed in (podcast.feed_url for podcast in feeds - if podcast.pubsubhubbub == url): - assert ("hub.url", url_feed) in data, \ - "Pair (hub.url, %s) not in %s" % (url_feed, data) - MyLittleRequests.num_feeds += 1 - - class MyLittleResponse(object): - def raise_for_status(self): - pass - MyLittleRequests.num_requests += 1 - return MyLittleResponse() - - Podcast.notify_multiple(MyLittleRequests, feeds) - self.assertEqual(MyLittleRequests.num_feeds, 3) - self.assertEqual(MyLittleRequests.num_requests, 1) - - # Test with timeout - wanted_timeout = 50.0 - Podcast.notify_multiple(MyLittleRequests, feeds, timeout=wanted_timeout) - self.assertEqual(MyLittleRequests.num_feeds, 6) - self.assertEqual(MyLittleRequests.num_requests, 2) - - # Test with one feed with another pubsubhubbub - wanted_timeout = None - feeds.append(Podcast( - name="Test4", - description="Testing another pubsubhubbub", - website="http://example.com", - explicit=True, - feed_url="http://example.com/feed4.rss", - pubsubhubbub=url_other_hub - )) - Podcast.notify_multiple(MyLittleRequests, feeds) - self.assertEqual(MyLittleRequests.num_feeds, 10) - self.assertEqual(MyLittleRequests.num_requests, 4) - if __name__ == '__main__': unittest.main() From a610cfda7296e05372e6f77d05b4120197ffd423 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Thu, 14 Jul 2016 13:53:54 +0200 Subject: [PATCH 115/200] Release v1.0.0b3 --- podgen/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/podgen/version.py b/podgen/version.py index 388bc03..8f68289 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -11,7 +11,7 @@ """ 'Version of python-podgen represented as tuple' -version = (1, 0, "0b2") +version = (1, 0, "0b3") 'Version of python-podgen represented as string' From 306c44c89b9c9a8192960fad0c82d95e1c8ec37d Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 16 Jul 2016 18:00:17 +0200 Subject: [PATCH 116/200] Add method for downloading the media file --- podgen/media.py | 47 ++++++++++++++++++++++++++++++++++++++ podgen/tests/test_media.py | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/podgen/media.py b/podgen/media.py index 0fe6478..8cdf368 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -9,6 +9,8 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +import os +import tempfile import warnings from future.moves.urllib.parse import urlparse from future.utils import raise_from @@ -367,3 +369,48 @@ def __str__(self): def __repr__(self): return self.__str__() + + def download(self, requests, destination): + """Download the media file. + + This method will block until the file is downloaded in its entirety. + + .. note:: + + The destination will not be populated atomically; if you need this, + you must give provide a temporary file as destination and rename the + file yourself. + + :param requests: The requests library, or its Session object. + :type requests: requests or requests.Session + :param destination: Where to save the media file. Either a filename, + or a file-like object. The file-like object will *not* be closed by + PodGen. + :type destination: :obj:`fd` or :obj:`str`. + """ + + r = requests.get(self.url, stream=True) + r.raise_for_status() + fd = None + destination_is_fd = hasattr(destination, "write") + try: + if destination_is_fd: + fd = destination + else: + fd = open(destination, "wb") + for chunk in r.iter_content(chunk_size=None): + fd.write(chunk) + del chunk + except (Exception, KeyboardInterrupt, InterruptedError): + # Don't leave half-finished files laying around. + if fd and not destination_is_fd: + try: + fd.close() + os.remove(destination) + except FileNotFoundError: + pass + raise + finally: + if fd and not destination_is_fd: + # Close the file we've opened (doesn't hurt to close twice) + fd.close() diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index aa1cda0..de829ed 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -8,10 +8,14 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +import os +import tempfile + from future.utils import iteritems import unittest import warnings from datetime import timedelta +import io from podgen import Media, NotSupportedByItunesWarning @@ -244,4 +248,44 @@ def raise_for_status(): self.assertEqual(m.type, type) self.assertEqual(m.duration, self.duration) + def test_downloadMedia(self): + class MyLittleRequests(object): + @staticmethod + def get(*args, **kwargs): + self.assertEqual(args[0], self.url) + is_streaming = kwargs.get("stream") + + class MyLittleResponse(object): + if is_streaming: + content = "binary content".encode("UTF-8") + + @staticmethod + def iter_content(chunk_size): + assert chunk_size is None or chunk_size >= 1024 + for char in "binary content": + yield char.encode("UTF-8") + + @staticmethod + def raise_for_status(): + pass + + return MyLittleResponse + + # Test that the given file object is used + m = Media(self.url, self.size, self.type) + fd = io.BytesIO() + m.download(MyLittleRequests, fd) + self.assertEqual(fd.getvalue().decode("UTF-8"), "binary content") + fd.close() + + # Test that the given filename is used + with tempfile.NamedTemporaryFile(delete=False) as fd: + filename = fd.name + try: + m.download(MyLittleRequests, filename) + with open(filename, "rb") as fd: + self.assertEqual(fd.read().decode("UTF-8"), "binary content") + finally: + os.remove(filename) + From 73715cba524102c8379fac427eaa96d2505155be Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 16 Jul 2016 21:40:25 +0200 Subject: [PATCH 117/200] Support computing a media file's duration --- doc/user/basic_usage_guide/part_2.rst | 83 ++++++++++++++++------ podgen/media.py | 99 ++++++++++++++++++++++----- podgen/tests/test_media.py | 56 +++++++++++++++ requirements.txt | 2 + setup.py | 2 +- 5 files changed, 203 insertions(+), 39 deletions(-) diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index c2fcbaa..01a1be9 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -71,26 +71,34 @@ Of course, this isn't much of a podcast if we don't have any duration=timedelta(hours=1, minutes=2, seconds=36) ) -The **type** of the media file is derived from the URI ending, if you don't -provide it yourself. Even though you -technically can have file names which don't end in their actual file extension, -iTunes will use the file extension to determine what type of file it is, without -even asking the server. You must therefore make sure your media files have the -correct file extension. If you don't care about compatibility with iTunes, you -can provide the MIME type yourself. - -The **duration** is also important to include for your listeners' convenience. -Without it, they won't know how long an episode is before they start downloading -and listening. It must be an instance of :class:`datetime.timedelta`. - -Normally, you must specify how big the **file size** is in bytes (and the `MIME -type`_ if the file extension is unknown to iTunes), but PodGen -can send a HEAD request to the URL and retrieve the missing information -(both file size and type). This is done by calling +The media's attributes (and the arguments to the constructor) are: + +:``url``: The URL at which this media file is accessible. +:``size``: The size of the media file as bytes, given either as :obj:`int` or + a :obj:`str` which will be parsed. +:``type``: The media file's `MIME type`_. +:``duration``: How long the media file lasts, given as a + :class:`datetime.timedelta` + +You can leave out some of these: + +:``url``: Mandatory. +:``size``: Can be 0, but do so only if you cannot determine its size (for + example if it's a stream). +:``type``: Can be left out if the URL has a recognized file extensions. In that + case, the type will be determined from the URL's file extension. +:``duration``: Can be left out since it is optional. It will stay as + :obj:`None`. + +Populating size and type from server +==================================== + +By using the special factory :meth:`Media.create_from_server_response ` -instead of using the constructor directly. -You must pass in the `requests `_ -module, so make sure it's installed. :: +you can gather missing information by asking the server at which the file is +hosted. This requires that you have installed the +`requests `_ library, and that you +pass it as the first parameter. Example:: import requests my_episode.media = Media.create_from_server_response( @@ -99,9 +107,44 @@ module, so make sure it's installed. :: duration=timedelta(hours=1, minutes=2, seconds=36) ) +Here's the effect of leaving out the fields: + +:``requests``: Mandatory. +:``url``: Mandatory. +:``size``: Will be populated using the ``Content-Length`` header. +:``type``: Will be populated using the ``Content-Type`` header. +:``duration``: Will *not* be populated by data from the server; will stay + :obj:`None`. + +Populating duration from server +=============================== + +Determining duration requires that the media file is downloaded to the local +machine, and is therefore not done unless you specifically ask for it. If you +don't have the media file locally, you can populate the :attr:`~.Media.duration` +field by using :meth:`.Media.fetch_duration`:: + + my_episode.media.fetch_duration(requests) + +If you *do* happen to have the media file in your file system, you can use it +to populate the :attr:`~.Media.duration` attribute by calling +:meth:`.Media.populate_duration_from`:: + + filename = "/home/example/Music/podcast/s01e10.mp3" + my_episode.media.populate_duration_from(filename) + .. note:: - The duration cannot be fetched from the server automatically. + Even though you technically can have file names which don't end in their + actual file extension, iTunes will use the file extension to determine what + type of file it is, without even asking the server. You must therefore make + sure your media files have the correct file extension. + + If you don't care about compatibility with iTunes, you can provide the MIME + type yourself to fix any errors you receive about this. + + This also applies to the tool used to determine a file's duration, which + uses the file's file extension to determine its type. Read more about: diff --git a/podgen/media.py b/podgen/media.py index 8cdf368..127aa20 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -16,6 +16,8 @@ from future.utils import raise_from import datetime +from tinytag import TinyTag + from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning from podgen import version @@ -115,6 +117,11 @@ def url(self, url): % parsed_url.scheme, NotSupportedByItunesWarning) self._url = url + @property + def file_extension(self): + """The file extension of :attr:`~.Media.url`. Read-only.""" + return '.' + urlparse(self.url).path.split('.')[-1] + @property def size(self): """The media's file size in bytes. @@ -164,26 +171,29 @@ def size(self, size): self.size = 0 else: raise e + @staticmethod def _str_to_bytes(size): - units = { - "b": 1, - "kb": 1000, - "kib": 1024, - "mb": 1000**2, - "mib": 1024**2, - "gb": 1000**3, - "gib": 1024**3, - "tb": 1000**4, - "tib": 1024**4 - } - size = str(size).lower().strip().replace(" ", "") - number = float(size.rstrip("bkimgt")) - unit = size.lstrip("0123456789.") - try: - return round(number * units[unit]) - except KeyError: - raise ValueError("The unit %s was not recognized." % unit) + """Parse ``size`` and return the number of bytes it names. + See :attr:`.Media.size` for more information on this conversion.""" + units = { + "b": 1, + "kb": 1000, + "kib": 1024, + "mb": 1000**2, + "mib": 1024**2, + "gb": 1000**3, + "gib": 1024**3, + "tb": 1000**4, + "tib": 1024**4 + } + size = str(size).lower().strip().replace(" ", "") + number = float(size.rstrip("bkimgt")) + unit = size.lstrip("0123456789.") + try: + return round(number * units[unit]) + except KeyError: + raise ValueError("The unit %s was not recognized." % unit) @property def type(self): @@ -309,6 +319,9 @@ def create_from_server_response(cls, requests, url, size=None, type=None, """Create new Media object, with size and/or type fetched from the server when not given. + See :meth:`.Media.fetch_duration` for a (slow!) way to fill in the + duration as well. + Like the signature suggests, this factory method requires that `Requests `_ is installed. @@ -414,3 +427,53 @@ def download(self, requests, destination): if fd and not destination_is_fd: # Close the file we've opened (doesn't hurt to close twice) fd.close() + + def populate_duration_from(self, filename): + """Populate :attr:`.Media.duration` by analyzing the given file. + + Use :meth:`.Media.fetch_duration` if you want to populate the + duration property of this object. + + :param filename: Path to the media file which shall be used to determine + this media's duration. The file extension must match its file type, + since it is used to determine what type of media file it is. For + a list of supported formats, see + https://pypi.python.org/pypi/tinytag/ + :type filename: str + """ + self.duration = self.get_duration_of(filename) + + @staticmethod + def get_duration_of(filename): + """Return the duration of the media file located at ``filename``. + + Use :meth:`.Media.fetch_duration` if you want to populate the + duration property of this object. + + :param filename: Path to the media file which shall be used to determine + this media's duration. The file extension must match its file type, + since it is used to determine what type of media file it is. For + a list of supported formats, see + https://pypi.python.org/pypi/tinytag/ + :type filename: str + :returns: datetime.timedelta + """ + return datetime.timedelta(seconds=TinyTag.get(filename).duration) + + def fetch_duration(self, requests): + """Download :attr:`.Media.url` locally and use it to populate + :attr:`.Media.duration`. + + This method will take quite some time, since the media file must be + downloaded before it can be analyzed. + """ + filename = None + try: + with tempfile.NamedTemporaryFile( + delete=False, suffix=self.file_extension) as fd: + filename = fd.name + self.download(requests, fd) + self.populate_duration_from(filename) + finally: + if filename: + os.remove(filename) diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index de829ed..e86def1 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -15,6 +15,7 @@ import unittest import warnings from datetime import timedelta +import mock import io from podgen import Media, NotSupportedByItunesWarning @@ -248,6 +249,48 @@ def raise_for_status(): self.assertEqual(m.type, type) self.assertEqual(m.duration, self.duration) + @mock.patch("os.remove", autospec=True) + @mock.patch("podgen.media.tempfile.NamedTemporaryFile", autospec=True) + @mock.patch("podgen.media.TinyTag", autospec=True) + def test_getDuration(self, mock_tinytag, mock_open, mock_rm): + # Create our fake requests module + mock_requests = mock.Mock() + # Prepare the response which the code will get from requests.get() + mock_requests_response = mock.Mock() + # The content (supposed to be binary mp3 file) + mock_requests_response.content = "binary data here" + # The content, as returned by an iterator (supposed to be chunks of + # mp3-file) + mock_requests_response.iter_content.return_value = range(5) + # Make sure our fake response is returned by requests.get() + mock_requests.get.return_value = mock_requests_response + + # Return the correct number of seconds from TinyTag + seconds = 14 * 60 + mock_tinytag.get.return_value.duration = seconds + + # Now do the actual testing + m = Media(self.url, self.size, self.type) + m.fetch_duration(mock_requests) + self.assertAlmostEqual(m.duration.total_seconds(), + seconds, places=0) + + # Check that the underlying libraries were used correctly + self.assertEqual(mock_requests.get.call_args[0][0], self.url) + if 'stream' in mock_requests.get.call_args[1] and \ + mock_requests.get.call_args[1]['stream']: + # The request is streamed, so iter_content was used + mock_requests_response.iter_content.assert_called_once() + fd = mock_open.return_value.__enter__.return_value + expected = [((i,),) for i in range(5)] + self.assertEqual(fd.write.call_args_list, expected) + else: + # The entire file was downloaded in one go + mock_open.return_value.__enter__.return_value.\ + write.assert_called_once_with("binary data here") + mock_rm.assert_called_once_with(mock_open.return_value. + __enter__.return_value.name) + def test_downloadMedia(self): class MyLittleRequests(object): @staticmethod @@ -288,4 +331,17 @@ def raise_for_status(): finally: os.remove(filename) + @mock.patch("podgen.media.TinyTag", autospec=True) + def test_calculateDuration(self, mock_tinytag): + # Return the correct number of seconds from TinyTag + seconds = 14.0 * 60.0 + mock_tinytag.get.return_value.duration = seconds + + filename = "my_little_file.mp3" + m = Media(self.url, self.size, self.type) + m.populate_duration_from(filename) + self.assertAlmostEqual(m.duration.total_seconds(), seconds, places=0) + # Check that the underlying library is used correctly + mock_tinytag.get.assert_called_once_with(filename) + diff --git a/requirements.txt b/requirements.txt index db067d1..ab749ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ dateutils lxml pytz future +mock +tinytag diff --git a/setup.py b/setup.py index 23221a4..e741989 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ url = 'http://podgen.readthedocs.io/en/latest/', keywords = ['feed', 'RSS', 'podcast', 'iTunes', 'generator'], license = 'FreeBSD and LGPLv3+', - install_requires = ['lxml', 'dateutils', 'future', 'pytz'], + install_requires = ['lxml', 'dateutils', 'future', 'pytz', 'tinytag'], classifiers = [ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', From e6c4db6b01a735a7fc4ec485816c44ac61e1706a Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 16 Jul 2016 21:53:28 +0200 Subject: [PATCH 118/200] Use native mock library if Python >= 3.4, attempting to fix bug in 3.4 --- podgen/tests/test_media.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index e86def1..f1f343d 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -15,7 +15,11 @@ import unittest import warnings from datetime import timedelta -import mock +import sys +if sys.version_info[0:1] >= (3,4): + from unittest import mock +else: + import mock import io from podgen import Media, NotSupportedByItunesWarning From 393e639ce05fb5a44c64274741f3675b9b92f837 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 16 Jul 2016 22:52:10 +0200 Subject: [PATCH 119/200] Try fixing travis bug by using trusty and not precise --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 84f775a..603f299 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ language: python +sudo: required +dist: trusty + python: # - "2.6" - "2.7" From 920aae491234a714984cbbbdcd5cf737122480ae Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 16 Jul 2016 23:05:41 +0200 Subject: [PATCH 120/200] Try yet again to fix bug that ONLY appears on Travis It is frustrating to just shoot in the dark.. In this commit, I try to explicitly define the mocks, instead of relying on them being created when accessed. --- podgen/tests/test_media.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index f1f343d..4521ea8 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -265,8 +265,10 @@ def test_getDuration(self, mock_tinytag, mock_open, mock_rm): mock_requests_response.content = "binary data here" # The content, as returned by an iterator (supposed to be chunks of # mp3-file) + mock_requests_response.iter_content = mock.Mock() mock_requests_response.iter_content.return_value = range(5) # Make sure our fake response is returned by requests.get() + mock_requests.get = mock.Mock() mock_requests.get.return_value = mock_requests_response # Return the correct number of seconds from TinyTag From acfe85c35aae1d39d2a764ddbba8864f3faeecc4 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 16 Jul 2016 23:17:04 +0200 Subject: [PATCH 121/200] Revert "Try yet again to fix bug that ONLY appears on Travis" This reverts commit 920aae491234a714984cbbbdcd5cf737122480ae. It did not make a difference, but I've found the bug. --- podgen/tests/test_media.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index 4521ea8..f1f343d 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -265,10 +265,8 @@ def test_getDuration(self, mock_tinytag, mock_open, mock_rm): mock_requests_response.content = "binary data here" # The content, as returned by an iterator (supposed to be chunks of # mp3-file) - mock_requests_response.iter_content = mock.Mock() mock_requests_response.iter_content.return_value = range(5) # Make sure our fake response is returned by requests.get() - mock_requests.get = mock.Mock() mock_requests.get.return_value = mock_requests_response # Return the correct number of seconds from TinyTag From 1a009172d6b4f7820471fb3915c95ef1d48ac35e Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 16 Jul 2016 23:18:56 +0200 Subject: [PATCH 122/200] Squash bug where Mock.assert_called_once was cheerfully called, despite not existing For some reason, despite autospec being in use, the undefined method was allowed. This bug in Mock seems to have been fixed in the version used on Travis, allowing me to catch this misuse of the API. --- podgen/tests/test_media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index f1f343d..304eb60 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -284,7 +284,7 @@ def test_getDuration(self, mock_tinytag, mock_open, mock_rm): if 'stream' in mock_requests.get.call_args[1] and \ mock_requests.get.call_args[1]['stream']: # The request is streamed, so iter_content was used - mock_requests_response.iter_content.assert_called_once() + self.assertEqual(mock_requests_response.iter_content.call_count, 1) fd = mock_open.return_value.__enter__.return_value expected = [((i,),) for i in range(5)] self.assertEqual(fd.write.call_args_list, expected) From 31760ede3a8c8d70bf60aa1da8c63cd12d3f36df Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 17 Jul 2016 02:34:32 +0200 Subject: [PATCH 123/200] Make requests a dependency, remove requests as parameter Backwards incompatible! Instead of passing requests as a parameter to all methods that required it, it is now stored as an instance variable, requests_session, which you can set in the Media constructor or later. Thus, when you download PodGen, you are assured that you can use all of its functionality without needing to install any extras. I don't think it's a problem to include as many libraries as we do. Most people are likely to use requests already anyway. --- doc/conf.py | 3 +- doc/user/basic_usage_guide/part_2.rst | 59 ++++++++++-------- podgen/media.py | 86 +++++++++++++++++---------- podgen/tests/test_media.py | 34 +++++++---- requirements.txt | 4 +- setup.py | 3 +- 6 files changed, 119 insertions(+), 70 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 632f0ee..9c4b5f2 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -277,7 +277,8 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None), + 'requests': ('http://docs.python-requests.org/en/master', None)} # Ugly way of setting tabsize diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index 01a1be9..bcc870d 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -55,6 +55,7 @@ Read more: * :attr:`~podgen.Episode.summary` * :attr:`~podgen.Episode.long_summary` +.. _podgen.Media-guide: Enclosing media ^^^^^^^^^^^^^^^ @@ -73,22 +74,31 @@ Of course, this isn't much of a podcast if we don't have any The media's attributes (and the arguments to the constructor) are: -:``url``: The URL at which this media file is accessible. -:``size``: The size of the media file as bytes, given either as :obj:`int` or - a :obj:`str` which will be parsed. -:``type``: The media file's `MIME type`_. -:``duration``: How long the media file lasts, given as a - :class:`datetime.timedelta` +======================== ======================================================= +Attribute Description +======================== ======================================================= +:attr:`~.Media.url` The URL at which this media file is accessible. +:attr:`~.Media.size` The size of the media file as bytes, given either as + :obj:`int` or a :obj:`str` which will be parsed. +:attr:`~.Media.type` The media file's `MIME type`_. +:attr:`~.Media.duration` How long the media file lasts, given as a + :class:`datetime.timedelta` +======================== ======================================================= You can leave out some of these: -:``url``: Mandatory. -:``size``: Can be 0, but do so only if you cannot determine its size (for - example if it's a stream). -:``type``: Can be left out if the URL has a recognized file extensions. In that - case, the type will be determined from the URL's file extension. -:``duration``: Can be left out since it is optional. It will stay as - :obj:`None`. +======================== ======================================================= +Attribute Effect if left out +======================== ======================================================= +:attr:`~.Media.url` Mandatory. +:attr:`~.Media.size` Can be 0, but do so only if you cannot determine its + size (for example if it's a stream). +:attr:`~.Media.type` Can be left out if the URL has a recognized file + extensions. In that case, the type will be determined + from the URL's file extension. +:attr:`~.Media.duration` Can be left out since it is optional. It will stay as + :obj:`None`. +======================== ======================================================= Populating size and type from server ==================================== @@ -96,25 +106,24 @@ Populating size and type from server By using the special factory :meth:`Media.create_from_server_response ` you can gather missing information by asking the server at which the file is -hosted. This requires that you have installed the -`requests `_ library, and that you -pass it as the first parameter. Example:: +hosted:: - import requests my_episode.media = Media.create_from_server_response( - requests, "http://example.com/podcast/s01e10.mp3", duration=timedelta(hours=1, minutes=2, seconds=36) ) Here's the effect of leaving out the fields: -:``requests``: Mandatory. -:``url``: Mandatory. -:``size``: Will be populated using the ``Content-Length`` header. -:``type``: Will be populated using the ``Content-Type`` header. -:``duration``: Will *not* be populated by data from the server; will stay - :obj:`None`. +======================== ======================================================= +Attribute Effect if left out +======================== ======================================================= +:attr:`~.Media.url` Mandatory. +:attr:`~.Media.size` Will be populated using the ``Content-Length`` header. +:attr:`~.Media.type` Will be populated using the ``Content-Type`` header. +:attr:`~.Media.duration` Will *not* be populated by data from the server; will + stay :obj:`None`. +======================== ======================================================= Populating duration from server =============================== @@ -124,7 +133,7 @@ machine, and is therefore not done unless you specifically ask for it. If you don't have the media file locally, you can populate the :attr:`~.Media.duration` field by using :meth:`.Media.fetch_duration`:: - my_episode.media.fetch_duration(requests) + my_episode.media.fetch_duration() If you *do* happen to have the media file in your file system, you can use it to populate the :attr:`~.Media.duration` attribute by calling diff --git a/podgen/media.py b/podgen/media.py index 127aa20..a0741f4 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -17,10 +17,17 @@ import datetime from tinytag import TinyTag +import requests from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning from podgen import version +def _get_new_requests_session(): + requests_session = requests.Session() + requests_session.headers['User-Agent'] = "%s v%s" % \ + (version.name, version.version_full_str) + return requests_session + class Media(object): """ @@ -67,7 +74,9 @@ class Media(object): size cannot be determined by any means (eg. if it's a stream) and duration which is optional (but recommended). - + .. seealso:: + :ref:`podgen.Media-guide` + for a more gentle introduction. """ file_types = { 'm4a': 'audio/x-m4a', @@ -79,7 +88,8 @@ class Media(object): 'epub': 'document/x-epub', } - def __init__(self, url, size=0, type=None, duration=None): + def __init__(self, url, size=0, type=None, duration=None, + requests_session=None): self._url = None self._size = None self._type = None @@ -89,6 +99,21 @@ def __init__(self, url, size=0, type=None, duration=None): self.size = size self.type = type or self.get_type(url) self.duration = duration + self.requests_session = requests_session or _get_new_requests_session() + """The requests.Session object which shall be used. Defaults to a new + session with PodGen as User-Agent. + + This is used by the instance methods :meth:`~.Media.download` and + :meth:`~.Media.fetch_duration`. + :meth:`~.Media.create_from_server_response`, however, creates its own + requests Session if not given as a parameter (since it is a static + method). + + You can set this attribute manually to set your own User-Agent and + benefit from Keep-Alive across different instances of Media. + + :type: :class:`requests.Session` + """ @property def url(self): @@ -119,7 +144,10 @@ def url(self, url): @property def file_extension(self): - """The file extension of :attr:`~.Media.url`. Read-only.""" + """The file extension of :attr:`~.Media.url`. Read-only. + + :type: :obj:`str` + """ return '.' + urlparse(self.url).path.split('.')[-1] @property @@ -314,34 +342,26 @@ def duration_str(self): return "%02d:%02d" % (minutes, seconds) @classmethod - def create_from_server_response(cls, requests, url, size=None, type=None, - duration=None): + def create_from_server_response(cls, url, size=None, type=None, + duration=None, requests_=None): """Create new Media object, with size and/or type fetched from the server when not given. See :meth:`.Media.fetch_duration` for a (slow!) way to fill in the duration as well. - Like the signature suggests, this factory method requires that - `Requests `_ is installed. - Example (assuming the server responds with Content-Length: 252345991 and Content-Type: audio/mpeg):: >>> from podgen import Media - >>> import requests # from requests package >>> # Assume an episode is hosted at example.com - >>> m = Media.create_from_server_response(requests, + >>> m = Media.create_from_server_response( ... "http://example.com/episodes/ep1.mp3") >>> m Media(url=http://example.com/episodes/ep1.mp3, size=252345991, type=audio/mpeg, duration=None) - :param requests: Either the - `requests `_ module - itself, or a Session object. - :type requests: requests :param url: The URL at which the media can be accessed right now. :type url: str :param size: Size of the file. Will be fetched from server if not given. @@ -350,15 +370,19 @@ def create_from_server_response(cls, requests, url, size=None, type=None, not given. :type type: str or None :param duration: The media's duration. - :type duration: :class:`datetime.timedelta` or :obj:`None`. + :type duration: :class:`datetime.timedelta` or :obj:`None` + :param requests_: Either the + `requests `_ module + itself, or a :class:`requests.Session` object. Defaults to a new + :class:`~requests.Session`. + :type requests_: :mod:`requests` or :class:`requests.Session` :returns: New instance of Media with url, size and type filled in. :raises: The appropriate requests exceptions are thrown when networking errors occur. RuntimeError is thrown if some information isn't given and isn't found in the server's response.""" if not (size and type): - r = requests.head(url, allow_redirects=True, timeout=5.0, - headers={"User-Agent": version.name + " v" + - version.version_full_str}) + requests_ = requests_ or _get_new_requests_session() + r = requests_.head(url, allow_redirects=True, timeout=10.0) r.raise_for_status() if not size: try: @@ -383,7 +407,7 @@ def __str__(self): def __repr__(self): return self.__str__() - def download(self, requests, destination): + def download(self, destination): """Download the media file. This method will block until the file is downloaded in its entirety. @@ -394,15 +418,13 @@ def download(self, requests, destination): you must give provide a temporary file as destination and rename the file yourself. - :param requests: The requests library, or its Session object. - :type requests: requests or requests.Session :param destination: Where to save the media file. Either a filename, or a file-like object. The file-like object will *not* be closed by PodGen. :type destination: :obj:`fd` or :obj:`str`. """ - r = requests.get(self.url, stream=True) + r = self.requests_session.get(self.url, stream=True) r.raise_for_status() fd = None destination_is_fd = hasattr(destination, "write") @@ -431,8 +453,9 @@ def download(self, requests, destination): def populate_duration_from(self, filename): """Populate :attr:`.Media.duration` by analyzing the given file. - Use :meth:`.Media.fetch_duration` if you want to populate the - duration property of this object. + Use this method when you have the media file on the local file system. + Use :meth:`.Media.fetch_duration` if you need to download the file from + the server. :param filename: Path to the media file which shall be used to determine this media's duration. The file extension must match its file type, @@ -441,14 +464,14 @@ def populate_duration_from(self, filename): https://pypi.python.org/pypi/tinytag/ :type filename: str """ - self.duration = self.get_duration_of(filename) + self.duration = self._get_duration_of(filename) @staticmethod - def get_duration_of(filename): + def _get_duration_of(filename): """Return the duration of the media file located at ``filename``. - Use :meth:`.Media.fetch_duration` if you want to populate the - duration property of this object. + Use :meth:`.Media.populate_duration_from` if you want to populate the + duration property of a Media instance using a local file. :param filename: Path to the media file which shall be used to determine this media's duration. The file extension must match its file type, @@ -460,10 +483,13 @@ def get_duration_of(filename): """ return datetime.timedelta(seconds=TinyTag.get(filename).duration) - def fetch_duration(self, requests): + def fetch_duration(self): """Download :attr:`.Media.url` locally and use it to populate :attr:`.Media.duration`. + Use this method when you don't have the media file on the local file + system. Use :meth:`~.Media.populate_duration_from` otherwise. + This method will take quite some time, since the media file must be downloaded before it can be analyzed. """ @@ -472,7 +498,7 @@ def fetch_duration(self, requests): with tempfile.NamedTemporaryFile( delete=False, suffix=self.file_extension) as fd: filename = fd.name - self.download(requests, fd) + self.download(fd) self.populate_duration_from(filename) finally: if filename: diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index 304eb60..48edd60 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -15,15 +15,14 @@ import unittest import warnings from datetime import timedelta -import sys -if sys.version_info[0:1] >= (3,4): - from unittest import mock -else: - import mock +import mock import io from podgen import Media, NotSupportedByItunesWarning +import podgen.media +# Because of a bug in the unittest.mock implementation, we must skip some tests +# in Python 3.4.2 class TestMedia(unittest.TestCase): def setUp(self): @@ -231,8 +230,6 @@ def head(*args, **kwargs): assert args[0] == url assert kwargs['allow_redirects'] == True assert 'timeout' in kwargs - assert 'headers' in kwargs - assert 'User-Agent' in kwargs['headers'] class MyLittleResponse(object): headers = { @@ -246,8 +243,8 @@ def raise_for_status(): return MyLittleResponse - m = Media.create_from_server_response(MyLittleRequests, url, - duration=self.duration) + m = Media.create_from_server_response(url, duration=self.duration, + requests_=MyLittleRequests) self.assertEqual(m.url, url) self.assertEqual(m.size, size) self.assertEqual(m.type, type) @@ -275,7 +272,8 @@ def test_getDuration(self, mock_tinytag, mock_open, mock_rm): # Now do the actual testing m = Media(self.url, self.size, self.type) - m.fetch_duration(mock_requests) + m.requests_session = mock_requests + m.fetch_duration() self.assertAlmostEqual(m.duration.total_seconds(), seconds, places=0) @@ -320,8 +318,9 @@ def raise_for_status(): # Test that the given file object is used m = Media(self.url, self.size, self.type) + m.requests_session = MyLittleRequests fd = io.BytesIO() - m.download(MyLittleRequests, fd) + m.download(fd) self.assertEqual(fd.getvalue().decode("UTF-8"), "binary content") fd.close() @@ -329,7 +328,7 @@ def raise_for_status(): with tempfile.NamedTemporaryFile(delete=False) as fd: filename = fd.name try: - m.download(MyLittleRequests, filename) + m.download(filename) with open(filename, "rb") as fd: self.assertEqual(fd.read().decode("UTF-8"), "binary content") finally: @@ -348,4 +347,15 @@ def test_calculateDuration(self, mock_tinytag): # Check that the underlying library is used correctly mock_tinytag.get.assert_called_once_with(filename) + @mock.patch("podgen.media.requests", autospec=True) + def test_create_requests_session(self, mock_requests): + # Mock cannot know that Session().headers is a dict + mock_requests.Session.return_value.headers = dict() + # Run the function under test + requests_session = podgen.media._get_new_requests_session() + # Did it return requests.Session()? + self.assertEqual(requests_session, mock_requests.Session.return_value) + # Did it set the User-Agent header so it includes podgen? + assert "podgen" in mock_requests.Session.return_value\ + .headers['User-Agent'] diff --git a/requirements.txt b/requirements.txt index ab749ce..90eef02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ -# Remember to add any new requirements to setup.py as well! +# Remember to add any new requirements to setup.py as well, if they are required +# for users of this package. dateutils lxml pytz future mock tinytag +requests diff --git a/setup.py b/setup.py index e741989..8e68d74 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,8 @@ url = 'http://podgen.readthedocs.io/en/latest/', keywords = ['feed', 'RSS', 'podcast', 'iTunes', 'generator'], license = 'FreeBSD and LGPLv3+', - install_requires = ['lxml', 'dateutils', 'future', 'pytz', 'tinytag'], + install_requires = ['lxml', 'dateutils', 'future', 'pytz', 'tinytag', + 'requests'], classifiers = [ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', From 4f5e757d1b13f341adeea5bd2661627c3ed5f2df Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 17 Jul 2016 15:36:17 +0200 Subject: [PATCH 124/200] Fix bug in which only lower-case file extensions were guessed correctly --- podgen/media.py | 2 +- podgen/tests/test_media.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/podgen/media.py b/podgen/media.py index a0741f4..878f1ee 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -283,7 +283,7 @@ def get_type(self, url): :returns: The guessed MIME type. :raises: ValueError if the MIME type couldn't be guessed from the URL. """ - file_extension = urlparse(url).path.split(".")[-1] + file_extension = urlparse(url).path.split(".")[-1].lower() try: return self.file_types[file_extension] except KeyError as e: diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index 48edd60..a667263 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -121,6 +121,7 @@ def test_autoRecognizeType(self): # https://help.apple.com/itc/podcasts_connect/#/itcb54353390 types = { '.mp3': set(["audio/mpeg"]), + '.MP3': set(["audio/mpeg"]), # case shouldn't matter '.m4a': set(["audio/x-m4a"]), '.mov': set(["video/quicktime"]), '.mp4': set(["video/mp4"]), From ca6d81eb2ea26a74158a91061aa0810ee87d0e62 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 17 Jul 2016 17:08:34 +0200 Subject: [PATCH 125/200] Work around bug kennethreitz/requests#3421 --- podgen/media.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/podgen/media.py b/podgen/media.py index 878f1ee..6f61c99 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -22,10 +22,17 @@ from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning from podgen import version + def _get_new_requests_session(): - requests_session = requests.Session() - requests_session.headers['User-Agent'] = "%s v%s" % \ - (version.name, version.version_full_str) + # TODO: Change into condition about requests' version once bug is fixed + if False: + requests_session = requests.Session() + requests_session.headers['User-Agent'] = "%s v%s" % \ + (version.name, version.version_full_str) + else: + # Currently work-around for bug in requests + # See #3421 (https://github.com/kennethreitz/requests/issues/3421) + requests_session = requests return requests_session From 9f1b216046dc4e4a2b9fdff8d6a8f383a6dfb8d6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 17 Jul 2016 17:10:02 +0200 Subject: [PATCH 126/200] Improve/fix warnings in Media --- podgen/media.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/podgen/media.py b/podgen/media.py index 6f61c99..ce45f63 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -142,11 +142,13 @@ def url(self, url): file_extension = parsed_url.path.split('.')[-1].lower() if file_extension not in self.file_types: warnings.warn("File extension %s is not supported by iTunes." - % file_extension, NotSupportedByItunesWarning) + % file_extension, NotSupportedByItunesWarning, + stacklevel=2) if parsed_url.scheme not in ("http", "https"): warnings.warn("URL scheme %s is not supported by iTunes. Make sure " "you use absolute URLs and HTTP or HTTPS." - % parsed_url.scheme, NotSupportedByItunesWarning) + % parsed_url.scheme, NotSupportedByItunesWarning, + stacklevel=2) self._url = url @property @@ -257,8 +259,8 @@ def type(self, type): type = type.strip().lower() if type not in self.file_types.values(): - warnings.warn("Media type %s is not supported by iTunes.", - NotSupportedByItunesWarning) + warnings.warn("Media type %s is not supported by iTunes." % type, + NotSupportedByItunesWarning, stacklevel=2) self._type = type def get_type(self, url): From 27791cc4c9204feeabdf9c40415f436b7d8f6e44 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 17 Jul 2016 17:15:46 +0200 Subject: [PATCH 127/200] Update tests to match changes in ca6d81e --- podgen/tests/test_media.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index a667263..ee65c23 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -349,7 +349,7 @@ def test_calculateDuration(self, mock_tinytag): mock_tinytag.get.assert_called_once_with(filename) @mock.patch("podgen.media.requests", autospec=True) - def test_create_requests_session(self, mock_requests): + def skip_test_create_requests_session(self, mock_requests): # Mock cannot know that Session().headers is a dict mock_requests.Session.return_value.headers = dict() # Run the function under test @@ -360,3 +360,9 @@ def test_create_requests_session(self, mock_requests): assert "podgen" in mock_requests.Session.return_value\ .headers['User-Agent'] + @mock.patch("podgen.media.requests", autospec=True) + def test_createRequestsSessionWorkaround(self, mock_requests): + # Run the function under test + requests_session = podgen.media._get_new_requests_session() + # Is it set to requests? + self.assertEqual(requests_session, mock_requests) From 845ce554ba936b49218bcf459ef5be4061502674 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 18 Jul 2016 00:04:01 +0200 Subject: [PATCH 128/200] Release v1.0.0b4 --- podgen/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/podgen/version.py b/podgen/version.py index 8f68289..7d2082e 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -11,7 +11,7 @@ """ 'Version of python-podgen represented as tuple' -version = (1, 0, "0b3") +version = (1, 0, "0b4") 'Version of python-podgen represented as string' From 7ecea16cbc7b64e349f304a87ee7daf78ae236bb Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 18 Jul 2016 00:04:58 +0200 Subject: [PATCH 129/200] Fix bug in which Media was incompatible with pickle --- podgen/media.py | 9 +++++++++ podgen/tests/test_media.py | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/podgen/media.py b/podgen/media.py index ce45f63..773fd27 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -416,6 +416,15 @@ def __str__(self): def __repr__(self): return self.__str__() + def __getstate__(self): + state = self.__dict__.copy() + del state['requests_session'] + return state + + def __setstate__(self, state): + self.__dict__.update(state) + self.requests_session = _get_new_requests_session() + def download(self, destination): """Download the media file. diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index ee65c23..a41ee1e 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -11,6 +11,7 @@ import os import tempfile +import pickle from future.utils import iteritems import unittest import warnings @@ -366,3 +367,13 @@ def test_createRequestsSessionWorkaround(self, mock_requests): requests_session = podgen.media._get_new_requests_session() # Is it set to requests? self.assertEqual(requests_session, mock_requests) + + @mock.patch("podgen.media.requests", autospec=True) + def test_pickling(self, mock_requests): + m = Media(self.url, self.size, self.type, self.duration) + m2 = pickle.loads(pickle.dumps(m)) + self.assertEqual(m.url, m2.url) + self.assertEqual(m.size, m2.size) + self.assertEqual(m.type, m2.type) + self.assertEqual(m.duration, m2.duration) + From 1eaa28cf415f5809d7e7d71412e044ab4ea242be Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 18 Jul 2016 16:11:56 +0200 Subject: [PATCH 130/200] Convert ValueError to warning, closes #53 You should be allowed to use whatever you want as image URL, but not without a warning that it won't work on iTunes :) --- podgen/episode.py | 11 ++++++-- podgen/podcast.py | 11 ++++++-- podgen/tests/test_episode.py | 53 +++++++++++++++++++++++++++++++++++- podgen/tests/test_media.py | 2 -- podgen/tests/test_podcast.py | 51 +++++++++++++++++++++++++++++++++- 5 files changed, 118 insertions(+), 10 deletions(-) diff --git a/podgen/episode.py b/podgen/episode.py index d756564..d748e10 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -8,10 +8,14 @@ :license: FreeBSD and LGPL, see license.* for more details. """ +import warnings + from lxml import etree from datetime import datetime import dateutil.parser import dateutil.tz + +from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning from podgen.util import formatRFC2822, listToHumanreadableStr from podgen.compat import string_types from builtins import str @@ -458,7 +462,8 @@ def image(self): 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". + (CMYK is not supported). The URL must end in ".jpg" or ".png"; a + :class:`.NotSupportedByItunesWarning` will be issued if it doesn't. :type: :obj:`str` :RSS: itunes:image @@ -487,8 +492,8 @@ def image(self, image): if image is not None: lowercase_image = str(image).lower() if not (lowercase_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not ' - '.%s' % image.split(".")[-1]) + warnings.warn('Image filename must end with png or jpg, not ' + '%s' % image.split(".")[-1], NotSupportedByItunesWarning) self.__image = image else: self.__image = None diff --git a/podgen/podcast.py b/podgen/podcast.py index 9340bd7..3556280 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -15,6 +15,7 @@ import dateutil.parser import dateutil.tz from podgen.episode import Episode +from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning from podgen.util import ensure_format, formatRFC2822, listToHumanreadableStr, \ htmlencode from podgen.person import Person @@ -1050,7 +1051,8 @@ def image(self): featured on the iTunes Store. 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". + (CMYK is not supported). The URL must end in ".jpg" or ".png"; if they + don't, a :class:`.NotSupportedByItunesWarning` will be issued. :type: :obj:`str` :RSS: itunes:image @@ -1070,8 +1072,11 @@ def image(self, image): if image is not None: lowercase_itunes_image = image.lower() if not (lowercase_itunes_image.endswith(('.jpg', '.jpeg', '.png'))): - raise ValueError('Image filename must end with png or jpg, not ' - '.%s' % image.split(".")[-1]) + warnings.warn\ + ( + 'Image URL must end with png or jpg, not ' + '%s' % image.split(".")[-1], NotSupportedByItunesWarning + ) self.__image = image else: self.__image = None diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index 07fba7c..7f4c63a 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -10,9 +10,12 @@ """ import unittest +import warnings + from lxml import etree -from podgen import Person, Media, Podcast, htmlencode, Episode +from podgen import Person, Media, Podcast, htmlencode, Episode, \ + NotSupportedByItunesWarning import datetime import pytz from dateutil.parser import parse as parsedate @@ -52,6 +55,11 @@ def setUp(self): self.fg = fg + warnings.simplefilter("always") + def noop(*args, **kwargs): + pass + warnings.showwarning = noop + def test_constructor(self): title = "A constructed episode" subtitle = "We're using the constructor!" @@ -432,6 +440,49 @@ def test_image(self): self.assertEqual(itunes_image.get("href"), image) assert itunes_image.text is None + def test_imageWarningNoExt(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.assertEqual(len(w), 0) + + # Set image to a URL without proper file extension + no_ext = "http://static.example.com/images/logo" + self.fe.image = no_ext + # Did we get a warning? + self.assertEqual(1, len(w)) + assert issubclass(w.pop().category, NotSupportedByItunesWarning) + # Was the image set? + self.assertEqual(no_ext, self.fe.image) + + def test_imageWarningBadExt(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Set image to a URL with an unsupported file extension + bad_ext = "http://static.example.com/images/logo.gif" + self.fe.image = bad_ext + # Did we get a warning? + self.assertEqual(1, len(w)) + # Was it of the correct type? + assert issubclass(w.pop().category, NotSupportedByItunesWarning) + # Was the image still set? + self.assertEqual(bad_ext, self.fe.image) + + def test_imageNoWarningWithGoodExt(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Set image to a URL with a supported file extension + extensions = ["jpg", "png", "jpeg"] + for extension in extensions: + good_ext = "http://static.example.com/images/logo." + extension + self.fe.image = good_ext + # Did we get no warning? + self.assertEqual(0, len(w), "Extension %s raised warnings (%s)" + % (extension, w)) + # Was the image set? + self.assertEqual(good_ext, self.fe.image) + def test_isClosedCaptioned(self): def get_element(): return self.fe.rss_entry()\ diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index a41ee1e..1db050e 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -22,8 +22,6 @@ from podgen import Media, NotSupportedByItunesWarning import podgen.media -# Because of a bug in the unittest.mock implementation, we must skip some tests -# in Python 3.4.2 class TestMedia(unittest.TestCase): def setUp(self): diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index 6c97555..a256db6 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -10,12 +10,14 @@ """ import unittest +import warnings + from lxml import etree import tempfile import os from future.utils import raise_from -from podgen import Person, Category, Podcast +from podgen import NotSupportedByItunesWarning, Person, Category, Podcast import podgen.version import datetime import dateutil.tz @@ -92,6 +94,11 @@ def setUp(self): self.fg = fg + warnings.simplefilter("always") + def noop(*args, **kwargs): + pass + warnings.showwarning = noop + def test_constructor(self): # Overwrite fg from setup self.fg = Podcast( @@ -539,6 +546,48 @@ def help_test_xslt_using(self, generated_feed): assert xslt_path in generated_feed(minimize=True) assert xslt_path in generated_feed(xml_declaration=False) + def test_imageWarningNoExt(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.assertEqual(len(w), 0) + + # Set image to a URL without proper file extension + no_ext = "http://static.example.com/images/logo" + self.fg.image = no_ext + # Did we get a warning? + self.assertEqual(1, len(w)) + assert issubclass(w.pop().category, NotSupportedByItunesWarning) + # Was the image set? + self.assertEqual(no_ext, self.fg.image) + + def test_imageWarningBadExt(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Set image to a URL with an unsupported file extension + bad_ext = "http://static.example.com/images/logo.gif" + self.fg.image = bad_ext + # Did we get a warning? + self.assertEqual(1, len(w)) + # Was it of the correct type? + assert issubclass(w.pop().category, NotSupportedByItunesWarning) + # Was the image still set? + self.assertEqual(bad_ext, self.fg.image) + + def test_imageNoWarningWithGoodExt(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Set image to a URL with a supported file extension + extensions = ["jpg", "png", "jpeg"] + for extension in extensions: + good_ext = "http://static.example.com/images/logo." + extension + self.fg.image = good_ext + # Did we get no warning? + self.assertEqual(0, len(w), "Extension %s raised warnings (%s)" + % (extension, w)) + # Was the image set? + self.assertEqual(good_ext, self.fg.image) if __name__ == '__main__': unittest.main() From 84b0990ba395b0b476729eb6d64a26f8b4fb9bc3 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 18 Jul 2016 16:29:06 +0200 Subject: [PATCH 131/200] Be a bit more helpful in warning message --- podgen/episode.py | 3 ++- podgen/podcast.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/podgen/episode.py b/podgen/episode.py index d748e10..fc53fda 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -493,7 +493,8 @@ def image(self, image): lowercase_image = str(image).lower() if not (lowercase_image.endswith(('.jpg', '.jpeg', '.png'))): warnings.warn('Image filename must end with png or jpg, not ' - '%s' % image.split(".")[-1], NotSupportedByItunesWarning) + '%s' % image.split(".")[-1], NotSupportedByItunesWarning, + stacklevel=2) self.__image = image else: self.__image = None diff --git a/podgen/podcast.py b/podgen/podcast.py index 3556280..66ce0dc 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -1075,7 +1075,8 @@ def image(self, image): warnings.warn\ ( 'Image URL must end with png or jpg, not ' - '%s' % image.split(".")[-1], NotSupportedByItunesWarning + '%s' % image.split(".")[-1], NotSupportedByItunesWarning, + stacklevel=2 ) self.__image = image else: From 68a8a3eca6cf6ee94beb2dbe781b77fccdbeffcb Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Fri, 26 Aug 2016 09:23:19 +0200 Subject: [PATCH 132/200] Release v1.0.0b5 --- podgen/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/podgen/version.py b/podgen/version.py index 7d2082e..067b60e 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -11,7 +11,7 @@ """ 'Version of python-podgen represented as tuple' -version = (1, 0, "0b4") +version = (1, 0, "0b5") 'Version of python-podgen represented as string' From 17104cb2dc57c108d83b848502e9a84b3f22a0f6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 10 May 2017 21:39:46 +0200 Subject: [PATCH 133/200] Mention the need to quote parts of the URL, fixes #56 --- doc/user/basic_usage_guide/part_2.rst | 13 +++++++++++++ podgen/media.py | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/user/basic_usage_guide/part_2.rst index bcc870d..55b8a90 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/user/basic_usage_guide/part_2.rst @@ -100,6 +100,19 @@ Attribute Effect if left out :obj:`None`. ======================== ======================================================= +.. warning:: + + Remember to encode special characters in your URLs! For example, say + you have a file named ``library-pod-#023-future.mp3``, which you host at + ``http://podcast.example.org/episodes``. You might try to use the URL + ``http://podcast.example.org/episodes/library-pod-#023-future.mp3``. This, + however, will not work, since the hash (#) has a special meaning in URLs. + Instead, you should use :func:`urllib.parse.quote` in Python3, or + :func:`urllib.quote` in Python2, to escape the special characters in the + filename in the URL. The correct URL would then become + ``http://podcast.example.org/episodes/library-pod-%23023-future.mp3``. + + Populating size and type from server ==================================== diff --git a/podgen/media.py b/podgen/media.py index 773fd27..7fdd31e 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -130,6 +130,13 @@ def url(self): https://. The server should support HEAD-requests and byte-range requests. + Ensure you quote parts of the URL that are not supposed to carry any + special meaning to the browser, typically the name of your file. + Common offenders include the slash character when not used to separate + folders, the hash mark (#) and the question mark (?). Use + :func:`urllib.parse.quote` in Python3 and :func:`urllib.quote` in + Python2. + :type: :obj:`str` """ return self._url From 7f5bcfab90986096c5d15259016a79d17fea4e44 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 10 May 2017 22:12:39 +0200 Subject: [PATCH 134/200] Add Sphinx as a dependency for development --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 90eef02..acddacb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ future mock tinytag requests +Sphinx From 6719e54300b51727fb908481e0cce308015c9911 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 10 May 2017 22:13:00 +0200 Subject: [PATCH 135/200] Fix typo regarding requirements.txt --- doc/contributing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/contributing.rst b/doc/contributing.rst index 8620b47..adb9d8d 100644 --- a/doc/contributing.rst +++ b/doc/contributing.rst @@ -7,7 +7,7 @@ Setting up To install the dependencies, run:: - $ pip install -r requirements + $ pip install -r requirements.txt while you have a `virtual environment `_ activated. From 804bdcb2d67ff4a1033e66d89007050cf326dff9 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 10 May 2017 22:23:38 +0200 Subject: [PATCH 136/200] Don't duplicate packages listed in setup.py, fixes #59 When importing a module from podgen in setup.py, an error occurred due to some imports assuming all dependencies were already installed. To fix this, the version number is now duplicated across both podgen.version and setup.py. --- requirements.txt | 12 ++++-------- setup.py | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/requirements.txt b/requirements.txt index acddacb..f6c41e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,6 @@ -# Remember to add any new requirements to setup.py as well, if they are required -# for users of this package. -dateutils -lxml -pytz -future +# Packages required by users of this library belong in setup.py +# Install using setup.py: +-e . +# Packages needed for development: mock -tinytag -requests Sphinx diff --git a/setup.py b/setup.py index 8e68d74..c51dae5 100755 --- a/setup.py +++ b/setup.py @@ -2,12 +2,12 @@ # -*- coding: utf-8 -*- from setuptools import setup -import podgen.version setup( name = 'podgen', packages = ['podgen'], - version = podgen.version.version_full_str, + # Remember to update the version in podgen.version, too! + version = '1.0.0b5', description = 'Generating podcasts with Python should be easy!', author = 'Thorben W. S. Dahl', author_email = 'thorben@sjostrom.no', From c26d9fcdfe88dee2cb543cf6fbfc81e73dbefc88 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 10 May 2017 22:25:29 +0200 Subject: [PATCH 137/200] Fix headings in readme.md --- readme.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/readme.md b/readme.md index 9932eec..3e07d74 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,3 @@ -=================================== PodGen (forked from python-feedgen) =================================== @@ -24,7 +23,6 @@ More details about the project: See the documentation link above for installation instructions and guides on how to use this module. ----------- Known bugs ---------- From 03125e3b928347400fde17d23a0fc552f62c4c97 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 10 May 2017 22:32:25 +0200 Subject: [PATCH 138/200] Add Travis CI test for Python 3.6 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 603f299..47f51c6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,7 @@ python: - "3.3" - "3.4" - "3.5" + - "3.6" before_install: pip install --quiet -r requirements.txt From bcd464d12e2128cd7bea7c2a2b0ee72384ef5a55 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 10 May 2017 22:37:38 +0200 Subject: [PATCH 139/200] Document Python 3.6 as a supported version --- doc/user/installation.rst | 4 ++-- setup.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/user/installation.rst b/doc/user/installation.rst index 87f1288..ceee88d 100644 --- a/doc/user/installation.rst +++ b/doc/user/installation.rst @@ -2,8 +2,8 @@ Installation ============ -PodGen can be used on any system (if not: file a bug report!), and supports -Python 2.7 and 3.3, 3.4 and 3.5. +PodGen can be used on any system (if not: file a bug report!), and officially supports +Python 2.7 and 3.3, 3.4, 3.5 and 3.6. Use `pip `_:: diff --git a/setup.py b/setup.py index c51dae5..1ce8740 100755 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Topic :: Communications', 'Topic :: Internet', 'Topic :: Software Development :: Libraries :: Python Modules', From 2f8438c2423e1913e50e5c6eaf23daa4c3772bd4 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 24 May 2017 18:48:05 +0200 Subject: [PATCH 140/200] Rewrite parts of "Why the fork?" It was a bit negative of python-feedgen, which it does not need to be. Indeed, many of the problems pointed out are not easy to fix, due to the number of features supported and backwards compatibility. --- doc/user/fork.rst | 50 +++++++++++++++-------------------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/doc/user/fork.rst b/doc/user/fork.rst index b0fc276..31c6283 100644 --- a/doc/user/fork.rst +++ b/doc/user/fork.rst @@ -15,13 +15,13 @@ to skip to :doc:`basic_usage_guide/part_1`. Inspiration ----------- -The reason I felt like making such drastic changes, is that the original library is -**exceptionally hard to learn** and use. Error messages would not tell you what was wrong, -the concept of extensions is poorly explained and the methods are a bit weird, in that -they function as getters and setters at the same time. The fact that you have three -separate ways to go about setting multi-value variables is also a bit confusing. +The python-feedgen_ project is alright for creating general RSS and ATOM feeds, +especially in situations where you'd like to serve the same content in those two +formats. However, I wanted to create podcasts, and found myself struggling with +getting the library to do what I wanted to do, and I frequently found myself +looking at the source to understand what was going on. -Perhaps the biggest problem, though, is the awkwardness that stems from enabling +Perhaps the biggest problem is the awkwardness that stems from enabling RSS and ATOM feeds through the same API. In case you don't know, ATOM is a competitor to RSS, and has many more capabilities than RSS. However, it is not used for podcasting. The result of mixing both ATOM and RSS include methods that will map an ATOM value to @@ -30,10 +30,10 @@ the value of the RSS property ``copyright``) and some differ in subtle ways (lik (ATOM) ``logo`` versus (RSS) ``image``). Other methods are more complex (see ``link``). They're all confusing, though, since changing one property automatically changes another implicitly. They also cause bugs, since it is so difficult to wrap your head around how one -interact with another. -Removing ATOM support fixes all these issues. +interact with another. This is the inspiration for forking python-feedgen_ and +rewrite the API, without mixing the different standards. -Even then, python-feedgen_ aims at being comprehensive, and gives you a one-to-one +Futhermore, python-feedgen_ gives you a one-to-one mapping to the resulting XML elements. This means that you must learn the RSS and podcast standards, which include many legacy elements you don't really need. For example, the original RSS spec @@ -45,30 +45,12 @@ I believe **the API should help guide the users** by hiding the legacy image tag and you as a user shouldn't need to know all this. You just need to know that the image must be larger than 1400x1400 pixels, not the history behind everything. -Alignment with the philosophies -------------------------------- - -python-feedgen_'s code breaks all the philosophies listed in the :doc:`introduction`: - -#. Beautiful is better than ugly, yet all properties are set through hybrid - setter/getter methods. -#. Explicit is better than implicit, yet changing one property will cause - changes to other properties implicitly. -#. Simple is better than complex, yet creating podcasts requires that you - load an extension, and somehow figure out that this extension's methods - are available as methods of the extension's name, which suddenly is - available as a property of your FeedGenerator object. -#. Complex is better than complicated, yet an entire framework is built to - handle extensions, rather than using class inheritance. (Said framework - even requires that the extension resides inside a specific folder!) -#. Readability counts, yet classes are named after their function and not what - they represent, and (again) properties are set through methods. - -In short, the **original project breaks all the idioms listed in Philosophy**, and -fixing it would require changes too big or too dramatic to be applied upstream. - -Whenever a change *is* appropriate for upstream, however, we should strive to -bring it there, so it can benefit **everyone**. +Forking a project gives you a lot of freedom, since you don't have to support +any old behaviour. python-feedgen_ cannot make backwards incompatible changes, +since many projects around the earth rely on the way the library works, and you +cannot expect everyone to use ``pip freeze`` (you should, though!). Whenever a +change *is* appropriate for python-feedgen_, however, we should strive to bring +it there so it can benefit as many as possible :) Summary of changes @@ -111,7 +93,7 @@ The following list is not exhaustive. * :attr:`.Podcast.explicit` is now required, and is :obj:`bool`. * Add shorthand for generating the RSS: Just try to converting your :class:`~podgen.Podcast` object to :obj:`str`! -* Improve the documentation (as you've surely noticed). +* Expand the documentation (as you've surely noticed). * Move away from the extension framework, and rely on class inheritance instead. .. _python-feedgen: https://github.com/lkiesow/python-feedgen From 0df70a5f9abdd93e2aeaa467ceb5f09cdce05df3 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 24 May 2017 19:15:35 +0200 Subject: [PATCH 141/200] Release version 1.0.0 --- podgen/version.py | 2 +- setup.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/podgen/version.py b/podgen/version.py index 067b60e..11231ea 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -11,7 +11,7 @@ """ 'Version of python-podgen represented as tuple' -version = (1, 0, "0b5") +version = (1, 0, 0) 'Version of python-podgen represented as string' diff --git a/setup.py b/setup.py index 1ce8740..685d051 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ name = 'podgen', packages = ['podgen'], # Remember to update the version in podgen.version, too! - version = '1.0.0b5', + version = '1.0.0', description = 'Generating podcasts with Python should be easy!', author = 'Thorben W. S. Dahl', author_email = 'thorben@sjostrom.no', @@ -17,7 +17,7 @@ install_requires = ['lxml', 'dateutils', 'future', 'pytz', 'tinytag', 'requests'], classifiers = [ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: Science/Research', From 1157fec2e3a71fe6d70cc713bd3276c02be1b02e Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 24 Jul 2017 12:31:42 +0200 Subject: [PATCH 142/200] Make extending example more correct --- doc/advanced/extending.rst | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/doc/advanced/extending.rst b/doc/advanced/extending.rst index b844ecb..bceba18 100644 --- a/doc/advanced/extending.rst +++ b/doc/advanced/extending.rst @@ -85,12 +85,9 @@ Using traditional inheritance # Initialize the ttl value self.__ttl = None - # Has the user passed in ttl value as a keyword? - if 'ttl' in kwargs: - self.ttl = kwargs['ttl'] - kwargs.pop('ttl') # avoid TypeError from super() - - # Call Podcast's constructor + # Call Podcast's constructor (this will set ttl using setattr if + # given as argument to the constructor, hence why self.__ttl is + # defined before we do this) super().__init__(*args, **kwargs) # If we were to use another namespace, we would add this here: @@ -147,10 +144,11 @@ Using traditional inheritance return rss # How to use the new class (normally, you would put this somewhere else) - myPodcast = PodcastWithTtl(name="Test", website="http://example.org", - explicit=False, description="Testing ttl") - myPodcast.ttl = 90 # or set ttl=90 in the constructor - print(myPodcast) + if __name__ == '__main__': + myPodcast = PodcastWithTtl(name="Test", website="http://example.org", + explicit=False, description="Testing ttl") + myPodcast.ttl = 90 # or set ttl=90 in the constructor + print(myPodcast) Using mixins From ef6010567dcfb979103af6d96f196c5e50710528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Tue, 7 Nov 2017 21:20:48 +0100 Subject: [PATCH 143/200] Make error message about missing fields mention correct fields --- podgen/podcast.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/podgen/podcast.py b/podgen/podcast.py index 66ce0dc..9620653 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -441,10 +441,10 @@ def _create_rss(self): channel = etree.SubElement(feed, 'channel') if not (self.name and self.website and self.description and self.explicit is not None): - missing = ', '.join(([] if self.name else ['title']) + - ([] if self.website else ['link']) + + missing = ', '.join(([] if self.name else ['name']) + + ([] if self.website else ['website']) + ([] if self.description else ['description']) + - ([] if self.explicit else ['itunes_explicit'])) + ([] if self.explicit is not None else ['explicit'])) raise ValueError('Required fields not set (%s)' % missing) title = etree.SubElement(channel, 'title') title.text = self.name From f783bce48c37fcd3867f0796bc4229c0d7c1394d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Sat, 12 Jan 2019 20:12:32 +0100 Subject: [PATCH 144/200] Ensure tests catch bug with missing encoding on write --- podgen/tests/test_podcast.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index a256db6..62c8571 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -11,6 +11,7 @@ import unittest import warnings +import locale from lxml import etree import tempfile @@ -26,6 +27,8 @@ class TestPodcast(unittest.TestCase): def setUp(self): + self.existing_locale = locale.setlocale(locale.LC_ALL, None) + locale.setlocale(locale.LC_ALL, 'C') fg = Podcast() @@ -36,7 +39,8 @@ def setUp(self): self.name = 'Some Testfeed' - self.author = Person('John Doe', 'john@example.de') + # Use character not in ASCII to catch encoding errors + self.author = Person('Jon Døll', 'jon@example.com') self.website = 'http://example.com' self.description = 'This is a cool feed!' @@ -99,6 +103,9 @@ def noop(*args, **kwargs): pass warnings.showwarning = noop + def tearDown(self): + locale.setlocale(locale.LC_ALL, self.existing_locale) + def test_constructor(self): # Overwrite fg from setup self.fg = Podcast( From 60a09c31c6e1fcb0c41257b9f7415c0f5c0d953f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Sat, 12 Jan 2019 21:01:46 +0100 Subject: [PATCH 145/200] Ensure an encoding is used when testing file writing --- podgen/tests/test_podcast.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index 62c8571..97e52f8 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -174,6 +174,7 @@ def getRssFeedFileContents(self, fg, **kwargs): # Keep track of our temporary file and its filename filename = None file = None + encoding = 'UTF-8' try: # Get our temporary file name file = tempfile.NamedTemporaryFile(delete=False) @@ -181,9 +182,9 @@ def getRssFeedFileContents(self, fg, **kwargs): # Close the file; we will just use its name file.close() # Write the RSS to the file (overwriting it) - fg.rss_file(filename=filename, **kwargs) + fg.rss_file(filename=filename, encoding=encoding, **kwargs) # Read the resulting RSS - with open(filename, "r") as myfile: + with open(filename, "r", encoding=encoding) as myfile: rssString = myfile.read() finally: # We don't need the file any longer, so delete it From 53cf7ebe68aee7e5abbce04d14f63bdcec4b092d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Sat, 12 Jan 2019 21:04:31 +0100 Subject: [PATCH 146/200] Fix #65 encoding not being specified when writing to file --- podgen/podcast.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/podgen/podcast.py b/podgen/podcast.py index 9620653..6431d8d 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -668,7 +668,8 @@ def rss_file(self, filename, minimize=False, File-like objects given to this method will not be closed. - :param filename: Name of file to write, or a file-like object, or a URL. + :param filename: Name of file to write, or a file-like object (accepting + string/unicode, not bytes). :type filename: str or fd :param minimize: Set to True to disable splitting the feed into multiple lines and adding properly indentation, saving bytes at the cost of @@ -686,7 +687,7 @@ def rss_file(self, filename, minimize=False, # Have we got a filename, or a file-like object? if isinstance(filename, string_types): # It is a string, assume it is filename - with open(filename, "w") as fd: + with open(filename, "w", encoding=encoding) as fd: fd.write(rss) elif hasattr(filename, "write"): # It is file-like enough to fool us From a2b977707bfb6c33f192143a2c959898daf87741 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 13:44:58 +0200 Subject: [PATCH 147/200] Do not hide output of pip install --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 47f51c6..e875830 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,4 @@ python: - "3.5" - "3.6" -before_install: pip install --quiet -r requirements.txt - script: make test From c7b45ce159f0dde685bada493f98ec5a268ad6b6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 14:10:34 +0200 Subject: [PATCH 148/200] Avoid incompatible LXML versions in Python3.3, 3.4 --- .travis.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.travis.yml b/.travis.yml index e875830..c58f106 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,4 +11,11 @@ python: - "3.5" - "3.6" +install: + # lxml dropped support for Python 3.3 in version 4.3.0 + - if [[ $TRAVIS_PYTHON_VERSION == 3.3 ]]; then pip install lxml<4.3.0; fi + # lxml dropped support for Python 3.4 in version 4.4.0 + - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install lxml<4.4.0; fi + - pip install -r requirements.txt + script: make test From be490d9a21468ecc8d15d1cda75f1e2c7c20207f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 14:16:14 +0200 Subject: [PATCH 149/200] Escape version specification --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index c58f106..b8d6a7a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,9 +13,9 @@ python: install: # lxml dropped support for Python 3.3 in version 4.3.0 - - if [[ $TRAVIS_PYTHON_VERSION == 3.3 ]]; then pip install lxml<4.3.0; fi + - if [[ $TRAVIS_PYTHON_VERSION == 3.3 ]]; then pip install 'lxml<4.3.0'; fi # lxml dropped support for Python 3.4 in version 4.4.0 - - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install lxml<4.4.0; fi + - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install 'lxml<4.4.0'; fi - pip install -r requirements.txt script: make test From 15498df967b59a81d15b163b6274dfb70b8f8c07 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 15:09:47 +0200 Subject: [PATCH 150/200] Work around error in tinytag wheel --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b8d6a7a..2a8456a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,8 @@ python: install: # lxml dropped support for Python 3.3 in version 4.3.0 - - if [[ $TRAVIS_PYTHON_VERSION == 3.3 ]]; then pip install 'lxml<4.3.0'; fi + # Must work around bug in tinytag https://github.com/devsnd/tinytag/issues/71 + - if [[ $TRAVIS_PYTHON_VERSION == 3.3 ]]; then pip install --no-binary tinytag 'lxml<4.3.0' tinytag; fi # lxml dropped support for Python 3.4 in version 4.4.0 - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install 'lxml<4.4.0'; fi - pip install -r requirements.txt From 6d052345746ca13a71fe6af24faeae793af589fd Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 15:43:27 +0200 Subject: [PATCH 151/200] Use newer pip version on Travis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 2a8456a..f56f59d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ python: - "3.6" install: + - pip install --upgrade pip # lxml dropped support for Python 3.3 in version 4.3.0 # Must work around bug in tinytag https://github.com/devsnd/tinytag/issues/71 - if [[ $TRAVIS_PYTHON_VERSION == 3.3 ]]; then pip install --no-binary tinytag 'lxml<4.3.0' tinytag; fi From 7b5dcf5bee18019ca8b14a8e292e8643313fbc8a Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 15:55:39 +0200 Subject: [PATCH 152/200] Give up Python 3.3 on Travis Also means giving up support for Python 3.3 for PodGen. I haven't seen it in the wild in recent times, so I doubt this should be a problem. --- .travis.yml | 8 +------- doc/index.rst | 2 +- readme.md | 2 +- setup.py | 1 - 4 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.travis.yml b/.travis.yml index f56f59d..defdc7a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,15 @@ language: python sudo: required -dist: trusty +dist: xenial python: -# - "2.6" - "2.7" - - "3.3" - "3.4" - "3.5" - "3.6" install: - - pip install --upgrade pip - # lxml dropped support for Python 3.3 in version 4.3.0 - # Must work around bug in tinytag https://github.com/devsnd/tinytag/issues/71 - - if [[ $TRAVIS_PYTHON_VERSION == 3.3 ]]; then pip install --no-binary tinytag 'lxml<4.3.0' tinytag; fi # lxml dropped support for Python 3.4 in version 4.4.0 - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install 'lxml<4.4.0'; fi - pip install -r requirements.txt diff --git a/doc/index.rst b/doc/index.rst index 3e70bcd..34e8480 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -43,7 +43,7 @@ head around ambiguous, undocumented APIs. PodGen incorporates the industry's best practices and lets you focus on collecting the necessary metadata and publishing the podcast. -PodGen is compatible with Python 2.7 and 3.3+. +PodGen is compatible with Python 2.7 and 3.4+. User Guide diff --git a/readme.md b/readme.md index 3e07d74..cdcb245 100644 --- a/readme.md +++ b/readme.md @@ -7,7 +7,7 @@ PodGen (forked from python-feedgen) This module can be used to generate podcast feeds in RSS format, and is -compatible with Python 2.7 and 3.3+. +compatible with Python 2.7 and 3.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 diff --git a/setup.py b/setup.py index 685d051..f4a3f41 100755 --- a/setup.py +++ b/setup.py @@ -29,7 +29,6 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', From 0dd4312489bd2ebaa4ab361aaffbe908fdbe020f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 19:39:12 +0200 Subject: [PATCH 153/200] Document change of supported Python versions --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..97aa29f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [Unreleased] +### Added + +- This `CHANGELOG.md` file, for documenting notable changes. + +### Removed + +- Support for Python 3.3, due to its age and lack of support, and bugs with + installing `tinytag`. + + +## [1.0.0] - 2017-05-24 +### Added + +- The Podcast and Episode classes for easily generating a podcast out of data, + and related utilities and classes. From fdfd3547f68671565ae84b2aaa4be5503d675a62 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 19:40:11 +0200 Subject: [PATCH 154/200] Add metadata about supported Python versions --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f4a3f41..be75856 100755 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ license = 'FreeBSD and LGPLv3+', install_requires = ['lxml', 'dateutils', 'future', 'pytz', 'tinytag', 'requests'], + python_requires = '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', classifiers = [ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', From 4e72c4307deec8a83fe8f92d4d5d8d55dccbbb10 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 19:41:32 +0200 Subject: [PATCH 155/200] Test with Python 3.7 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index defdc7a..3063649 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ python: - "3.4" - "3.5" - "3.6" + - "3.7" install: # lxml dropped support for Python 3.4 in version 4.4.0 From e486ba343663979ae34f7b7a2a087dd16e945975 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 21:16:24 +0200 Subject: [PATCH 156/200] Ensure strings are handled the same in Py2 & 3 --- podgen/__init__.py | 4 ++++ podgen/__main__.py | 3 +++ podgen/category.py | 5 +++++ podgen/episode.py | 15 +++++++++------ podgen/media.py | 8 ++++++-- podgen/not_supported_by_itunes_warning.py | 5 +++++ podgen/person.py | 5 +++++ podgen/podcast.py | 4 ++++ podgen/tests/test_category.py | 4 ++++ podgen/tests/test_episode.py | 4 ++++ podgen/tests/test_media.py | 6 +++++- podgen/tests/test_person.py | 4 ++++ podgen/tests/test_podcast.py | 6 +++++- podgen/tests/test_util.py | 4 ++++ podgen/util.py | 4 ++++ podgen/version.py | 3 +++ 16 files changed, 74 insertions(+), 10 deletions(-) diff --git a/podgen/__init__.py b/podgen/__init__.py index 4c041e3..13e87c6 100644 --- a/podgen/__init__.py +++ b/podgen/__init__.py @@ -10,6 +10,10 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + from .podcast import Podcast from .episode import Episode from .media import Media diff --git a/podgen/__main__.py b/podgen/__main__.py index cb5813a..5168fa5 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -7,6 +7,9 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * import sys import datetime diff --git a/podgen/category.py b/podgen/category.py index d236c99..31054d6 100644 --- a/podgen/category.py +++ b/podgen/category.py @@ -8,6 +8,11 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + + class Category(object): """Immutable class representing an iTunes category. diff --git a/podgen/episode.py b/podgen/episode.py index fc53fda..976f1be 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -8,6 +8,11 @@ :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * +from future.utils import iteritems + import warnings from lxml import etree @@ -18,8 +23,6 @@ from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning from podgen.util import formatRFC2822, listToHumanreadableStr from podgen.compat import string_types -from builtins import str -from future.utils import iteritems class Episode(object): @@ -503,14 +506,14 @@ def image(self, image): def explicit(self): """Whether this podcast episode contains material which may be inappropriate for children. - + The value of the podcast's explicit attribute is used by default, if this is kept as ``None``. If you set this to ``True``, an "explicit" parental advisory - graphic will appear in the Name column in iTunes. If the value is - ``False``, the parental advisory type is considered Clean, meaning that - no explicit language or adult content is included anywhere in this + graphic will appear in the Name column in iTunes. If the value is + ``False``, the parental advisory type is considered Clean, meaning that + no explicit language or adult content is included anywhere in this episode, and a "clean" graphic will appear. :type: :obj:`bool` diff --git a/podgen/media.py b/podgen/media.py index 7fdd31e..0a0a75d 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -9,11 +9,15 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * +from future.moves.urllib.parse import urlparse +from future.utils import raise_from + import os import tempfile import warnings -from future.moves.urllib.parse import urlparse -from future.utils import raise_from import datetime from tinytag import TinyTag diff --git a/podgen/not_supported_by_itunes_warning.py b/podgen/not_supported_by_itunes_warning.py index 76fa011..6480a78 100644 --- a/podgen/not_supported_by_itunes_warning.py +++ b/podgen/not_supported_by_itunes_warning.py @@ -9,5 +9,10 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + + class NotSupportedByItunesWarning(UserWarning): pass diff --git a/podgen/person.py b/podgen/person.py index 50929d5..fd7fcaf 100644 --- a/podgen/person.py +++ b/podgen/person.py @@ -9,6 +9,11 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + + class Person(object): """Data-oriented class representing a single person or entity. diff --git a/podgen/podcast.py b/podgen/podcast.py index 6431d8d..c047e0e 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -9,7 +9,11 @@ :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * from future.utils import iteritems + from lxml import etree from datetime import datetime import dateutil.parser diff --git a/podgen/tests/test_category.py b/podgen/tests/test_category.py index 75476e2..e80d7f0 100644 --- a/podgen/tests/test_category.py +++ b/podgen/tests/test_category.py @@ -8,6 +8,10 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + import unittest from podgen import Category diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index 7f4c63a..be4e503 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -8,6 +8,9 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * import unittest import warnings @@ -20,6 +23,7 @@ import pytz from dateutil.parser import parse as parsedate + class TestBaseEpisode(unittest.TestCase): def setUp(self): diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py index 1db050e..7ceb4ea 100644 --- a/podgen/tests/test_media.py +++ b/podgen/tests/test_media.py @@ -8,11 +8,15 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * +from future.utils import iteritems + import os import tempfile import pickle -from future.utils import iteritems import unittest import warnings from datetime import timedelta diff --git a/podgen/tests/test_person.py b/podgen/tests/test_person.py index 7e5d49d..99c9726 100644 --- a/podgen/tests/test_person.py +++ b/podgen/tests/test_person.py @@ -8,6 +8,10 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + import unittest from podgen import Person diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index 97e52f8..c5573f6 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -9,6 +9,11 @@ :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * +from future.utils import raise_from + import unittest import warnings import locale @@ -16,7 +21,6 @@ from lxml import etree import tempfile import os -from future.utils import raise_from from podgen import NotSupportedByItunesWarning, Person, Category, Podcast import podgen.version diff --git a/podgen/tests/test_util.py b/podgen/tests/test_util.py index 1c9b4eb..75db31a 100644 --- a/podgen/tests/test_util.py +++ b/podgen/tests/test_util.py @@ -8,6 +8,10 @@ :copyright: 2016, Thorben Dahl :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + import unittest from podgen import util diff --git a/podgen/util.py b/podgen/util.py index 27c29a5..8e64e2c 100644 --- a/podgen/util.py +++ b/podgen/util.py @@ -9,6 +9,10 @@ :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + import sys, locale diff --git a/podgen/version.py b/podgen/version.py index 11231ea..e2162b7 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -9,6 +9,9 @@ :license: FreeBSD and LGPL, see license.* for more details. """ +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * 'Version of python-podgen represented as tuple' version = (1, 0, 0) From fae1615f7a63746bf42e04dd580c0bae87f69426 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 5 Oct 2019 21:29:16 +0200 Subject: [PATCH 157/200] Mention the fixed issues in changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97aa29f..a63e917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for Python 3.3, due to its age and lack of support, and bugs with installing `tinytag`. +### Fixed + +- `UnicodeEncodeError` when writing a podcast with non-ASCII characters to file + in an environment where Python defaults to ASCII encoding. +- Incompatibility with unicode strings on Python 2.7. + ## [1.0.0] - 2017-05-24 ### Added - The Podcast and Episode classes for easily generating a podcast out of data, and related utilities and classes. + +[1.0.0]: https://github.com/tobinus/python-podgen/compare/290045ac...v1.0.0 From 5b9bf4a04546df4407d43f593df26bda56aebc9f Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 6 Oct 2019 00:57:06 +0200 Subject: [PATCH 158/200] Update and reorganize documentation Organize the documentation by overall "Background" and "Usage guide" with sub pages. Rename pages to ensure greater scannability. Remove the Waffle badge (since Waffle is defunct). Add note to README about missing newer Apple features. Update the example to a more timeless topic. Avoid contributing to the cult status of individuals. Make some adjustments to the layout of the doc pages. Fix broken links to the Requests documentation. --- doc/{user => background}/fork.rst | 12 ++-- doc/background/index.rst | 13 ++++ doc/background/license.rst | 9 +++ doc/background/philosophy.rst | 31 ++++++++++ doc/background/scope.rst | 31 ++++++++++ doc/conf.py | 7 ++- doc/contributing.rst | 6 +- doc/index.rst | 44 +++++++------- .../part_2.rst => usage_guide/episodes.rst} | 8 +-- doc/{user => usage_guide}/example.rst | 0 doc/usage_guide/index.rst | 12 ++++ doc/usage_guide/installation.rst | 24 ++++++++ .../part_1.rst => usage_guide/podcasts.rst} | 8 +-- .../part_3.rst => usage_guide/rss.rst} | 4 +- doc/user/installation.rst | 15 ----- doc/user/introduction.rst | 59 ------------------- podgen/episode.py | 2 +- podgen/podcast.py | 8 --- readme.md | 9 ++- 19 files changed, 166 insertions(+), 136 deletions(-) rename doc/{user => background}/fork.rst (90%) create mode 100644 doc/background/index.rst create mode 100644 doc/background/license.rst create mode 100644 doc/background/philosophy.rst create mode 100644 doc/background/scope.rst rename doc/{user/basic_usage_guide/part_2.rst => usage_guide/episodes.rst} (98%) rename doc/{user => usage_guide}/example.rst (100%) create mode 100644 doc/usage_guide/index.rst create mode 100644 doc/usage_guide/installation.rst rename doc/{user/basic_usage_guide/part_1.rst => usage_guide/podcasts.rst} (97%) rename doc/{user/basic_usage_guide/part_3.rst => usage_guide/rss.rst} (96%) delete mode 100644 doc/user/installation.rst delete mode 100644 doc/user/introduction.rst diff --git a/doc/user/fork.rst b/doc/background/fork.rst similarity index 90% rename from doc/user/fork.rst rename to doc/background/fork.rst index 31c6283..a9cdf03 100644 --- a/doc/user/fork.rst +++ b/doc/background/fork.rst @@ -10,7 +10,7 @@ request which removes 70% of the features ;-) Among other things, support for AT Dublin Core is removed, and the remaining code is almost entirely rewritten. A more detailed reasoning follows. Read it if you're interested, but feel free -to skip to :doc:`basic_usage_guide/part_1`. +to skip to the :doc:`/usage_guide/index`. Inspiration ----------- @@ -46,11 +46,9 @@ and you as a user shouldn't need to know all this. You just need to know that th image must be larger than 1400x1400 pixels, not the history behind everything. Forking a project gives you a lot of freedom, since you don't have to support -any old behaviour. python-feedgen_ cannot make backwards incompatible changes, -since many projects around the earth rely on the way the library works, and you -cannot expect everyone to use ``pip freeze`` (you should, though!). Whenever a -change *is* appropriate for python-feedgen_, however, we should strive to bring -it there so it can benefit as many as possible :) +any old behaviour. It would be difficult to make these changes upstream, since +many of the problems are inherent to the scope and purpose of the library itself, +and changing that is difficult and not always desirable. Summary of changes @@ -93,7 +91,7 @@ The following list is not exhaustive. * :attr:`.Podcast.explicit` is now required, and is :obj:`bool`. * Add shorthand for generating the RSS: Just try to converting your :class:`~podgen.Podcast` object to :obj:`str`! -* Expand the documentation (as you've surely noticed). +* Expand the documentation. * Move away from the extension framework, and rely on class inheritance instead. .. _python-feedgen: https://github.com/lkiesow/python-feedgen diff --git a/doc/background/index.rst b/doc/background/index.rst new file mode 100644 index 0000000..e0acc67 --- /dev/null +++ b/doc/background/index.rst @@ -0,0 +1,13 @@ +========== +Background +========== + +Learn about the "why" and "how" of the PodGen project itself. + +.. toctree:: + :maxdepth: 1 + + philosophy + scope + fork + license diff --git a/doc/background/license.rst b/doc/background/license.rst new file mode 100644 index 0000000..6c992b8 --- /dev/null +++ b/doc/background/license.rst @@ -0,0 +1,9 @@ +------- +License +------- +PodGen 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_. + +.. _license.bsd: https://github.com/tobinus/python-podgen/blob/master/license.bsd +.. _license.lgpl: https://github.com/tobinus/python-podgen/blob/master/license.lgpl diff --git a/doc/background/philosophy.rst b/doc/background/philosophy.rst new file mode 100644 index 0000000..12ce3d2 --- /dev/null +++ b/doc/background/philosophy.rst @@ -0,0 +1,31 @@ +---------- +Philosophy +---------- + +This project is heavily inspired by the "for humans" approach of the +`Requests `__ library, which features an API that +is designed to give the developer a great user experience. This is done by +finding a suitable scope and abstraction level, and designing the +API so it supports the developer's vocabulary and their mental model of how +the domain works. + +For example, instead of using the names of XML tags like "itunes:image", a more +relevant name, here simply "image", is used. Another example is the duration of +a podcast episode. In XML terms, this is put into an "itunes:duration" tag which exists +outside of the "enclosure" tag, which holds the filename and file size. In PodGen, +the filename, file size, file type and audio duration are all placed together in +a Media instance, since they are all related to the media itself. The goal +has been to "hide" the messy details of the XML and provide an API on top which +uses words that you recognize and use daily when working with podcasts. + +To be specific, PodGen aims to follow the same +`PEP 20 `__ idioms as +`Requests `__: + +1. Beautiful is better than ugly. +2. Explicit is better than implicit. +3. Simple is better than complex. +4. Complex is better than complicated. +5. Readability counts. + +To enable this, the project focuses on one task alone: making it easy to generate a podcast. diff --git a/doc/background/scope.rst b/doc/background/scope.rst new file mode 100644 index 0000000..1986e16 --- /dev/null +++ b/doc/background/scope.rst @@ -0,0 +1,31 @@ +----- +Scope +----- + +This library does NOT help you publish a podcast, or manage the metadata of your +podcasts. It's just a tool that accepts information about your podcast and +outputs an RSS feed which you can then publish however you want. + +Both the process of getting information +about your podcast, and publishing it needs to be done by you. Even then, +it will save you from hammering your head over confusing and undocumented APIs +and conflicting views on how different RSS elements should be used. It also +saves you from reading the RSS specification, the RSS Best Practices and the +documentation for iTunes' Podcast Connect. + +Here is an example of how PodGen fits into your code: + +1. A request comes to your webserver (using e.g. `Flask `__) +2. A podcast router starts to handle the request. +3. The database is queried for information about the requested podcast. +4. **The data retrieved from the database is "translated" into the language of PodGen, using its Podcast, Episode, People and Media classes.** +5. **The RSS document is generated by PodGen and saved to a variable.** +6. The generated RSS document is made into a response and sent to the client. + +PodGen is geared towards developers who aren't super familiar with +RSS and XML. If you know exactly how you want the XML to look, then you're +better off using a template engine like Jinja2 (even if friends don't let +friends touch XML bare-handed) or an XML processor like the built-in +`Python ElementTree API `__. +If you just want an easy way to create and manage your podcasts, +check out systems like `Podcast Generator `_. diff --git a/doc/conf.py b/doc/conf.py index 9c4b5f2..1fbfd03 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -45,7 +45,7 @@ # General information about the project. project = u'PodGen' -copyright = u'2014, Lars Kiesow. Modified work © 2016, Thorben Dahl' +copyright = u'2014, Lars Kiesow. Modified work © 2019, Thorben Dahl' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -112,6 +112,9 @@ 'gray_2': "rgba(0, 0, 0, 0.2)", 'gray_3': "rgba(198, 198, 198, 0.9)", + 'description': 'Generate podcasts with ease.', + 'show_relbars': True, + 'github_user': 'tobinus', 'github_repo': 'python-podgen', 'github_banner': True, @@ -278,7 +281,7 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {'python': ('https://docs.python.org/3', None), - 'requests': ('http://docs.python-requests.org/en/master', None)} + 'requests': ('https://requests.readthedocs.io/en/master', None)} # Ugly way of setting tabsize diff --git a/doc/contributing.rst b/doc/contributing.rst index adb9d8d..496be75 100644 --- a/doc/contributing.rst +++ b/doc/contributing.rst @@ -35,9 +35,9 @@ The unit tests reside in ``podgen/tests`` and are written using the Values ------ -Read :doc:`/user/introduction` and :doc:`/user/fork` for a run-down on what -values/principles lay the foundation for this project. In short, it is important -to keep the API as simple as possible. +Read :doc:`/background/philosophy`, :doc:`/background/scope` and :doc:`/background/fork` +for a run-down on what values/principles lay the foundation for this project. +In short, it is important to keep the API as simple as possible. You must also write unittests as you code, ideally using **test-driven development** (that is, write a test, observe that the test fails, write code diff --git a/doc/index.rst b/doc/index.rst index 34e8480..c426302 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -9,34 +9,35 @@ PodGen :target: http://podgen.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -.. image:: https://badge.waffle.io/tobinus/python-podgen.svg?label=ready&title=Ready - :target: https://waffle.io/tobinus/python-podgen - :alt: 'Stories in Ready' - Don't you wish there was a **clean and simple library** which could help you **generate podcast RSS feeds** with your Python code? Well, today's your lucky day! >>> from podgen import Podcast, Episode, Media >>> # Create the Podcast >>> p = Podcast( - name="The Library Tuesday Talk", - description="My friends and I discuss Python" - " libraries each Tuesday!", - website="http://example.org/librarytuesdaytalk" + name="Animals Alphabetically", + description="Every Tuesday, biologist John Doe and wildlife " + "photographer Foo Bar introduce you to a new animal.", + website="http://example.org/animals-alphabetically" ) >>> # Add some episodes >>> p.episodes += [ - Episode(title="Worry about timezones no more", - media=Media("http://example.org/ep1.mp3", 11932295), - summary="Using pytz, you can make your code timezone-aware " - "with very little hassle."), - Episode(title="Heard about clint?", - media=Media("http://example.org/ep2.mp3", 15363464), - summary="The man behind Requests made something useful " - "for us command-line nerds." + Episode(title="Aardvark", + media=Media("http://example.org/files/aardvark.mp3", 11932295), + summary="With an English name adapted directly from Afrikaans " + "-- literally meaning "earth pig" -- this fascinating " + "animal has both circular teeth and a knack for " + "digging."), + Episode(title="Alpaca", + media=Media("http://example.org/files/alpaca", 15363464), + summary="Thousands of years ago, alpacas were already " + "domesticated and bred to produce the best fibers. " + "Case in point: we have found clothing made from " + "alpaca fiber that is 2000 years old. How is this " + "possible, and what makes it different from llamas?" ] >>> # Generate the RSS feed - >>> rss = str(p) + >>> rss = p.rss_str() You don't need to read the RSS specification, write XML by hand or wrap your head around ambiguous, undocumented APIs. PodGen incorporates the industry's @@ -52,13 +53,8 @@ User Guide .. toctree:: :maxdepth: 3 - user/introduction - user/installation - user/fork - user/basic_usage_guide/part_1 - user/basic_usage_guide/part_2 - user/basic_usage_guide/part_3 - user/example + background/index + usage_guide/index advanced/index contributing api diff --git a/doc/user/basic_usage_guide/part_2.rst b/doc/usage_guide/episodes.rst similarity index 98% rename from doc/user/basic_usage_guide/part_2.rst rename to doc/usage_guide/episodes.rst index 55b8a90..b748d3b 100644 --- a/doc/user/basic_usage_guide/part_2.rst +++ b/doc/usage_guide/episodes.rst @@ -1,6 +1,6 @@ -Adding episodes ---------------- +Episodes +-------- To add episodes to a feed, you need to create new :class:`podgen.Episode` objects and @@ -297,7 +297,3 @@ attribute name as the keyword:: ) See also the example in :doc:`the API Documentation `. - --------------------------------------------------------------------------------- - -The final step is :doc:`part_3`. diff --git a/doc/user/example.rst b/doc/usage_guide/example.rst similarity index 100% rename from doc/user/example.rst rename to doc/usage_guide/example.rst diff --git a/doc/usage_guide/index.rst b/doc/usage_guide/index.rst new file mode 100644 index 0000000..65964e3 --- /dev/null +++ b/doc/usage_guide/index.rst @@ -0,0 +1,12 @@ +=========== +Usage Guide +=========== + +.. toctree:: + :maxdepth: 1 + + installation + podcasts + episodes + rss + example diff --git a/doc/usage_guide/installation.rst b/doc/usage_guide/installation.rst new file mode 100644 index 0000000..ddd82d1 --- /dev/null +++ b/doc/usage_guide/installation.rst @@ -0,0 +1,24 @@ +============ +Installation +============ + +PodGen can be used on any system (if not: file a bug report!), and officially supports +Python 2.7 and 3.4, 3.5, 3.6 and 3.7. + +Use `pip `_:: + + $ pip install podgen + +Remember to use a `virtual environment `_! + +.. note:: + + One of the dependencies of PodGen, `lxml `_, stopped supporting + Python 3.4 in version 4.4.0. If you are installing PodGen using Python 3.4, you + should select a compatible version of lxml by running e.g.:: + + pip install 'lxml<4.4.0' + + The step "Running setup.py install for lxml" will take several minutes and + `requires installation of building tools `_, since the lxml version does not include + pre-built binaries. diff --git a/doc/user/basic_usage_guide/part_1.rst b/doc/usage_guide/podcasts.rst similarity index 97% rename from doc/user/basic_usage_guide/part_1.rst rename to doc/usage_guide/podcasts.rst index e53f43f..35885f1 100644 --- a/doc/user/basic_usage_guide/part_1.rst +++ b/doc/usage_guide/podcasts.rst @@ -1,5 +1,5 @@ -Creating the podcast --------------------- +Podcasts +-------- Creating a new instance ~~~~~~~~~~~~~~~~~~~~~~~ @@ -156,7 +156,3 @@ Using this technique, you can define the Podcast as part of a list comprehension, dictionaries and so on. Take a look at the :doc:`API Documentation for Podcast ` for a practical example. - --------------------------------------------------------------------------------- - -Next step is :doc:`part_2`. diff --git a/doc/user/basic_usage_guide/part_3.rst b/doc/usage_guide/rss.rst similarity index 96% rename from doc/user/basic_usage_guide/part_3.rst rename to doc/usage_guide/rss.rst index b28cb03..2edc2cd 100644 --- a/doc/user/basic_usage_guide/part_3.rst +++ b/doc/usage_guide/rss.rst @@ -1,6 +1,6 @@ -Generating the RSS ------------------- +RSS +--- Once you've added all the information and episodes, you're ready to take the final step:: diff --git a/doc/user/installation.rst b/doc/user/installation.rst deleted file mode 100644 index ceee88d..0000000 --- a/doc/user/installation.rst +++ /dev/null @@ -1,15 +0,0 @@ -============ -Installation -============ - -PodGen can be used on any system (if not: file a bug report!), and officially supports -Python 2.7 and 3.3, 3.4, 3.5 and 3.6. - -Use `pip `_:: - - $ pip install podgen - -Just a word of warning: PodGen depends on -`lxml `_, which can take several minutes to build. - -Remember to use a `virtual environment `_! diff --git a/doc/user/introduction.rst b/doc/user/introduction.rst deleted file mode 100644 index 87541fd..0000000 --- a/doc/user/introduction.rst +++ /dev/null @@ -1,59 +0,0 @@ -============ -Introduction -============ - - ----------- -Philosophy ----------- - -This project is heavily inspired by the wonderful -`Kenneth Reitz `__, known for the -`Requests `__ library, which features an API that is -as beautiful as it is effective. Watching his -`"Documentation is King" talk `__, -I wanted to make some of the libraries I'm using suitable for human consumption too. - -This project is to be developed following the same -`PEP 20 `__ idioms as -`Requests `__: - -1. Beautiful is better than ugly. -2. Explicit is better than implicit. -3. Simple is better than complex. -4. Complex is better than complicated. -5. Readability counts. - -To enable this, the project focuses on one task alone: making it easy to generate a podcast. - ------ -Scope ------ - -This library does NOT help you publish a podcast, or manage the metadata of your -podcasts. It's just a tool that accepts information about your podcast and -outputs an RSS feed which you can then publish however you want. - -Both the process of getting information -about your podcast, and publishing it needs to be done by you. Even then, -it will save you from hammering your head over confusing and undocumented APIs -and conflicting views on how different RSS elements should be used. It also -saves you from reading the RSS specification, the RSS Best Practices and the -documentation for iTunes' Podcast Connect. - -PodGen is geared towards developers who aren't super familiar with -RSS and XML. If you know exactly how you want the XML to look, then you're -better off using a template engine like Jinja2 (even if friends don't let -friends touch XML bare-handed). If you just want an easy way to create and -manage your podcasts, use `Podcast Generator `_. - -------- -License -------- -PodGen 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_. - -.. _license.bsd: https://github.com/tobinus/python-podgen/blob/master/license.bsd -.. _license.lgpl: https://github.com/tobinus/python-podgen/blob/master/license.lgpl - diff --git a/podgen/episode.py b/podgen/episode.py index 976f1be..6ccd3b7 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -64,7 +64,7 @@ class Episode(object): .. seealso:: - :doc:`/user/basic_usage_guide/part_2` + :doc:`/usage_guide/episodes` A friendlier introduction to episodes. """ diff --git a/podgen/podcast.py b/podgen/podcast.py index c047e0e..2a0b8a7 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -310,15 +310,7 @@ def __init__(self, **kwargs): ``xml-stylesheet``, with type set to ``text/xsl`` and href set to this attribute. - .. note:: - - Firefox will use its own stylesheet for RSS feeds, so you - must test using another browser and possibly a `simple web server`_ - (``python -m http.server 8000 -b 127.0.0.1``). - .. _XSLT: https://en.wikipedia.org/wiki/XSLT - .. _simple web server: - https://docs.python.org/3/library/http.server.html """ # Populate the podcast with the keyword arguments diff --git a/readme.md b/readme.md index cdcb245..1972c6b 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,6 @@ PodGen (forked from python-feedgen) [![Build Status](https://travis-ci.org/tobinus/python-podgen.svg?branch=master)](https://travis-ci.org/tobinus/python-podgen) [![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) -[![Stories in Ready](https://badge.waffle.io/tobinus/python-podgen.svg?label=ready&title=Ready)](http://waffle.io/tobinus/python-podgen) This module can be used to generate podcast feeds in RSS format, and is @@ -23,9 +22,13 @@ More details about the project: See the documentation link above for installation instructions and guides on how to use this module. -Known bugs ----------- +Known bugs and limitations +-------------------------- +* The updates to Apple's podcasting guidelines since 2016 have not been + implemented. This includes the new categories, the ability to mark episodes + with episode and season number, and the ability to mark the podcast as + "serial". It is a goal to implement those changes in a future release. * We do not follow the RSS recommendation to encode &, < and > using hexadecimal character reference (eg. `<`), simply because lxml provides no documentation on how to do that when using the text property. From 56291b8cab934e1559976c0b8b9b1fef5f4a3e5b Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 6 Oct 2019 01:09:40 +0200 Subject: [PATCH 159/200] Document documentation changes in the changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a63e917..818710a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - This `CHANGELOG.md` file, for documenting notable changes. +### Changed + +- Organization of the documentation, along with other documentation + improvements and updates. + ### Removed - Support for Python 3.3, due to its age and lack of support, and bugs with From 7f8c3d67cf09b7823bba21f09dda085949333c31 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 6 Oct 2019 13:23:20 +0200 Subject: [PATCH 160/200] Improve usability and design of documentation --- doc/_static/custom.css | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/doc/_static/custom.css b/doc/_static/custom.css index 9c8d85e..216ad54 100644 --- a/doc/_static/custom.css +++ b/doc/_static/custom.css @@ -1,15 +1,40 @@ -body, div.body { +body { background-color: #fffded; } +div.documentwrapper, div.body { + background: initial; +} pre { background-color: #ebebeb; } -div.sphinxsidebar a.current { +div.sphinxsidebar a.current, div.sphinxsidebar a:visited.current { + color: rgba(0, 0, 0, 0.9); text-decoration: none; border-bottom: none; font-weight: bold; cursor: text; } +div.body { + min-width: auto; +} +div.document, div.footer { + width: auto; + max-width: 1000px; +} +a:link, +div.sphinxsidebar a:link { + color: #004B6B; +} +a:visited, div.sphinxsidebar a:visited { + color: #56006B; +} +a.current + ul a[href*="#"] { + color: rgba(0, 0, 0, 0.9); +} +div.toctree-wrapper a[href*="#"] +{ + color: rgba(0, 28, 40, 0.65); +} /* Don't hide logo when viewed on mobile, just invert its colors */ @media screen and (max-width: 875px) { div.sphinxsidebar p.logo { From a5ab6ef0f77f7401b6fe6e59c7136946b48df177 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 6 Oct 2019 13:23:33 +0200 Subject: [PATCH 161/200] Explain core concepts a bit better --- doc/usage_guide/episodes.rst | 8 ++++---- doc/usage_guide/podcasts.rst | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/doc/usage_guide/episodes.rst b/doc/usage_guide/episodes.rst index b748d3b..80717cb 100644 --- a/doc/usage_guide/episodes.rst +++ b/doc/usage_guide/episodes.rst @@ -2,10 +2,10 @@ Episodes -------- -To add episodes to a feed, you need to create new -:class:`podgen.Episode` objects and -append them to the list of episodes in the Podcast. That is pretty -straight-forward:: +Once you have created and populated a Podcast, you probably want to add some +episodes to it. +To add episodes to a feed, you need to create new :class:`podgen.Episode` objects and +append them to the list of episodes in the Podcast. That is pretty straight-forward:: from podgen import Podcast, Episode # Create the podcast (see the previous section) diff --git a/doc/usage_guide/podcasts.rst b/doc/usage_guide/podcasts.rst index 35885f1..c2daf1c 100644 --- a/doc/usage_guide/podcasts.rst +++ b/doc/usage_guide/podcasts.rst @@ -1,10 +1,15 @@ Podcasts -------- +In PodGen, the term *podcast* refers to the show which listeners can subscribe to, +which consists of individual *episodes*. Therefore, the Podcast class will be the +first thing you start with. + Creating a new instance ~~~~~~~~~~~~~~~~~~~~~~~ -:: +You can start with a blank podcast by invoking the Podcast constructor with no +arguments, like this:: from podgen import Podcast p = Podcast() @@ -12,14 +17,19 @@ Creating a new instance Mandatory attributes ~~~~~~~~~~~~~~~~~~~~ -:: +There are four attributes which must be set before you can generate your podcast. +They are mandatory because Apple's podcast directory will not accept podcasts without +this information. If you try to generate the podcast without setting all of the +mandatory attributes, you will get an error. + +The mandatory attributes are:: p.name = "My Example Podcast" p.description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit." p.website = "https://example.org" - p.explicit = True + p.explicit = False -They're self explanatory, but you can read more about them if you'd like: +They're mostly self explanatory, but you can read more about them if you'd like: * :attr:`~podgen.Podcast.name` * :attr:`~podgen.Podcast.description` From 3079933f9366cfad06e89f3f2a55a7df8618e6e9 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Mon, 7 Oct 2019 21:55:32 +0200 Subject: [PATCH 162/200] Fix example, add links --- CHANGELOG.md | 3 +-- doc/_static/custom.css | 2 +- doc/conf.py | 7 +++++- doc/index.rst | 53 +++++++++++++++++++++++++++++------------- setup.py | 1 + 5 files changed, 46 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 818710a..5700a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -- Support for Python 3.3, due to its age and lack of support, and bugs with - installing `tinytag`. +- Support for Python 3.3, due to its age and lack of support. ### Fixed diff --git a/doc/_static/custom.css b/doc/_static/custom.css index 216ad54..88653a6 100644 --- a/doc/_static/custom.css +++ b/doc/_static/custom.css @@ -7,7 +7,7 @@ div.documentwrapper, div.body { pre { background-color: #ebebeb; } -div.sphinxsidebar a.current, div.sphinxsidebar a:visited.current { +div.sphinxsidebar a:link.current, div.sphinxsidebar a:visited.current { color: rgba(0, 0, 0, 0.9); text-decoration: none; border-bottom: none; diff --git a/doc/conf.py b/doc/conf.py index 1fbfd03..5a7126f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -117,9 +117,14 @@ 'github_user': 'tobinus', 'github_repo': 'python-podgen', - 'github_banner': True, 'logo_name': False, 'logo': 'logo.png', + + 'extra_nav_links': { + 'Changelog': 'https://github.com/tobinus/python-podgen/blob/master/CHANGELOG.md', + 'GitHub': 'https://github.com/tobinus/python-podgen/tree/master', + 'PyPI': 'https://pypi.org/project/podgen/', + }, } # Add any paths that contain custom themes here, relative to this directory. diff --git a/doc/index.rst b/doc/index.rst index c426302..e40a73e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -18,23 +18,28 @@ Don't you wish there was a **clean and simple library** which could help you name="Animals Alphabetically", description="Every Tuesday, biologist John Doe and wildlife " "photographer Foo Bar introduce you to a new animal.", - website="http://example.org/animals-alphabetically" + website="http://example.org/animals-alphabetically", + explicit=False, ) >>> # Add some episodes >>> p.episodes += [ - Episode(title="Aardvark", - media=Media("http://example.org/files/aardvark.mp3", 11932295), - summary="With an English name adapted directly from Afrikaans " - "-- literally meaning "earth pig" -- this fascinating " - "animal has both circular teeth and a knack for " - "digging."), - Episode(title="Alpaca", - media=Media("http://example.org/files/alpaca", 15363464), - summary="Thousands of years ago, alpacas were already " - "domesticated and bred to produce the best fibers. " - "Case in point: we have found clothing made from " - "alpaca fiber that is 2000 years old. How is this " - "possible, and what makes it different from llamas?" + Episode( + title="Aardvark", + media=Media("http://example.org/files/aardvark.mp3", 11932295), + summary="With an English name adapted directly from Afrikaans " + '-- literally meaning "earth pig" -- this fascinating ' + "animal has both circular teeth and a knack for " + "digging.", + ), + Episode( + title="Alpaca", + media=Media("http://example.org/files/alpaca.mp3", 15363464), + summary="Thousands of years ago, alpacas were already " + "domesticated and bred to produce the best fibers. " + "Case in point: we have found clothing made from " + "alpaca fiber that is 2000 years old. How is this " + "possible, and what makes it different from llamas?", + ), ] >>> # Generate the RSS feed >>> rss = p.rss_str() @@ -46,9 +51,17 @@ publishing the podcast. PodGen is compatible with Python 2.7 and 3.4+. +.. warning:: -User Guide ----------- + As of October 7th 2019 (v1.0.1), PodGen does not support the additions and changes + made by Apple to their podcast standards since 2016. This includes + the new and renamed categories, the ability to mark episodes with episode and + season number, and the ability to mark the podcast as "serial". + It is a goal to implement those changes in a new release in 2019. + + +Contents +-------- .. toctree:: :maxdepth: 3 @@ -58,3 +71,11 @@ User Guide advanced/index contributing api + + +External Resources +------------------ + +* `Changelog `_ +* `GitHub Repository `_ +* `Python Package Index `_ diff --git a/setup.py b/setup.py index be75856..c85cfa2 100755 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Communications', 'Topic :: Internet', 'Topic :: Software Development :: Libraries :: Python Modules', From 995ed99ca137cc9be0e9abbc95ba60fee88786df Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 8 Oct 2019 19:36:09 +0200 Subject: [PATCH 163/200] Ignore settings for Visual Studio Code --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1ef9b92..2859dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ venv # JetBrains IDE .idea/ +# Visual Studio Code +.vscode/ + # Documentation build /doc/_build /docs @@ -15,4 +18,3 @@ venv /MANIFEST /build /podgen.egg-info - From 94b3ae6b9c9d314e76816551538527e447c0993b Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 8 Oct 2019 20:52:46 +0200 Subject: [PATCH 164/200] Ensure proper contrast for links in footer on mobile --- doc/_static/custom.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/doc/_static/custom.css b/doc/_static/custom.css index 88653a6..a87316e 100644 --- a/doc/_static/custom.css +++ b/doc/_static/custom.css @@ -43,4 +43,19 @@ div.toctree-wrapper a[href*="#"] width: 60%; margin: auto; } + + /* Different link colors */ + div.sphinxsidebar a:link { + color: #BFD2DA; + } + + div.sphinxsidebar a:visited { + color: #C09FC7; + } + + a.current + ul a[href*="#"], + div.sphinxsidebar a:link.current, + div.sphinxsidebar a:visited.current { + color: rgba(255, 255, 255, 0.95); + } } From bac3a32240ed41a77047f454ced084fde4f63051 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 8 Oct 2019 20:53:17 +0200 Subject: [PATCH 165/200] Add roadmap --- doc/background/index.rst | 1 + doc/background/roadmap.rst | 42 ++++++++++++++++++++++++++++++++++++++ doc/index.rst | 1 + 3 files changed, 44 insertions(+) create mode 100644 doc/background/roadmap.rst diff --git a/doc/background/index.rst b/doc/background/index.rst index e0acc67..304d43b 100644 --- a/doc/background/index.rst +++ b/doc/background/index.rst @@ -10,4 +10,5 @@ Learn about the "why" and "how" of the PodGen project itself. philosophy scope fork + roadmap license diff --git a/doc/background/roadmap.rst b/doc/background/roadmap.rst new file mode 100644 index 0000000..984c38d --- /dev/null +++ b/doc/background/roadmap.rst @@ -0,0 +1,42 @@ +------- +Roadmap +------- + +When PodGen reaches a certain point where it has all the features it needs, +while still being simple to use, it would not be necessary with further +updates… had it not been for changes to PodGen's dependencies and changes to +the overall podcast standards. Luckily, the applications that read podcasts +tend to be backwards compatible, in order to support all the old podcasts that +are out there. + +The current plan for PodGen updates is as follows: + +* **New minor version**: Support for the new Apple Podcast specifications and + categories, as much as is possible without breaking backwards compatibility. +* Deprecation warnings for properties and such which will be removed in the + next major version. +* **New major version**: Support for the new Apple Podcast specifications and + categories which could not be included earlier due to backwards compatibility, + or which have new names or behaviours to simplify the API. Removal of + deprecated features. +* **New minor versions**: Removal of support for Python releases that have + passed `End of Life`_ (Python 2.7 after January 1st 2020, Python + 3.4), allowing for simplifications and use of new features in the code base. + Other code and documentation improvements should hopefully stop PodGen from + becoming stale and burdensome to maintain. +* Better ways of adding support for RSS features which PodGen itself does not + support. + +.. _End of life: https://devguide.python.org/#status-of-python-branches + +Unlike other libraries with evolving demands, PodGen is expected to stay +relatively stable with the occasional update. Changes to the Apple Podcast +specifications do not occur particularly often, so PodGen won't need to change +often either. + +Since PodGen is an open source project and its maintainer has a limited amount +of time to spare, updates may be sporadic. Despite this, please don't hesitate +to report issues or feature requests if there is something that does not +work or you feel should be included. The project is used by the Student Radio +of Trondheim and will be kept up-to-date with their requirements, though they +are not primarily a podcast producer. diff --git a/doc/index.rst b/doc/index.rst index e40a73e..aefe2be 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -58,6 +58,7 @@ PodGen is compatible with Python 2.7 and 3.4+. the new and renamed categories, the ability to mark episodes with episode and season number, and the ability to mark the podcast as "serial". It is a goal to implement those changes in a new release in 2019. + Please refer to the :doc:`background/roadmap`. Contents From 76e743f5db6f00e256dfb0501bdd6f7c490a8910 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 8 Oct 2019 20:53:26 +0200 Subject: [PATCH 166/200] Make example non-interactive-style --- doc/index.rst | 67 ++++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index aefe2be..89e1442 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -9,40 +9,41 @@ PodGen :target: http://podgen.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -Don't you wish there was a **clean and simple library** which could help you -**generate podcast RSS feeds** with your Python code? Well, today's your lucky day! +Are you looking for a **clean and simple library** which helps you +**generate podcast RSS feeds** from your Python code? +Here is how you do that with PodGen:: - >>> from podgen import Podcast, Episode, Media - >>> # Create the Podcast - >>> p = Podcast( - name="Animals Alphabetically", - description="Every Tuesday, biologist John Doe and wildlife " - "photographer Foo Bar introduce you to a new animal.", - website="http://example.org/animals-alphabetically", - explicit=False, - ) - >>> # Add some episodes - >>> p.episodes += [ - Episode( - title="Aardvark", - media=Media("http://example.org/files/aardvark.mp3", 11932295), - summary="With an English name adapted directly from Afrikaans " - '-- literally meaning "earth pig" -- this fascinating ' - "animal has both circular teeth and a knack for " - "digging.", - ), - Episode( - title="Alpaca", - media=Media("http://example.org/files/alpaca.mp3", 15363464), - summary="Thousands of years ago, alpacas were already " - "domesticated and bred to produce the best fibers. " - "Case in point: we have found clothing made from " - "alpaca fiber that is 2000 years old. How is this " - "possible, and what makes it different from llamas?", - ), - ] - >>> # Generate the RSS feed - >>> rss = p.rss_str() + from podgen import Podcast, Episode, Media + # Create the Podcast + p = Podcast( + name="Animals Alphabetically", + description="Every Tuesday, biologist John Doe and wildlife " + "photographer Foo Bar introduce you to a new animal.", + website="http://example.org/animals-alphabetically", + explicit=False, + ) + # Add some episodes + p.episodes += [ + Episode( + title="Aardvark", + media=Media("http://example.org/files/aardvark.mp3", 11932295), + summary="With an English name adapted directly from Afrikaans " + '-- literally meaning "earth pig" -- this fascinating ' + "animal has both circular teeth and a knack for " + "digging.", + ), + Episode( + title="Alpaca", + media=Media("http://example.org/files/alpaca.mp3", 15363464), + summary="Thousands of years ago, alpacas were already " + "domesticated and bred to produce the best fibers. " + "Case in point: we have found clothing made from " + "alpaca fiber that is 2000 years old. How is this " + "possible, and what makes it different from llamas?", + ), + ] + # Generate the RSS feed + rss = p.rss_str() You don't need to read the RSS specification, write XML by hand or wrap your head around ambiguous, undocumented APIs. PodGen incorporates the industry's From 3bbf4bd967d8a97b6a1a3ed65927357b2d7f8786 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 8 Oct 2019 20:55:19 +0200 Subject: [PATCH 167/200] Mention roadmap in changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5700a58..6aea4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - This `CHANGELOG.md` file, for documenting notable changes. +- Documentation page about PodGen's roadmap (under Background). ### Changed From d6b7c1da45ff4c8b138b7b832b1a7d85820feea2 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 8 Oct 2019 21:25:04 +0200 Subject: [PATCH 168/200] Fix link for Unreleased --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aea4a8..81a743a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,4 +34,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Podcast and Episode classes for easily generating a podcast out of data, and related utilities and classes. +[Unreleased]: https://github.com/tobinus/python-podgen/compare/v1.0.0...develop [1.0.0]: https://github.com/tobinus/python-podgen/compare/290045ac...v1.0.0 From 24e3a9aa18aa955e5feb03dc557d8b273e9c3f67 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Tue, 8 Oct 2019 21:25:30 +0200 Subject: [PATCH 169/200] Release version 1.0.1b1 --- podgen/version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/podgen/version.py b/podgen/version.py index e2162b7..1748128 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -14,7 +14,7 @@ from builtins import * 'Version of python-podgen represented as tuple' -version = (1, 0, 0) +version = (1, 0, '1b1') 'Version of python-podgen represented as string' diff --git a/setup.py b/setup.py index c85cfa2..d843bfb 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ name = 'podgen', packages = ['podgen'], # Remember to update the version in podgen.version, too! - version = '1.0.0', + version = '1.0.1b1', description = 'Generating podcasts with Python should be easy!', author = 'Thorben W. S. Dahl', author_email = 'thorben@sjostrom.no', From e185d598a4ed94df7aad0e3257a6cc12dad05158 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 12 Oct 2019 14:03:56 +0200 Subject: [PATCH 170/200] Release v1.0.1 --- CHANGELOG.md | 5 +++-- podgen/version.py | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a743a..f4897ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.0.1] - 2019-10-12 ### Added - This `CHANGELOG.md` file, for documenting notable changes. @@ -34,5 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Podcast and Episode classes for easily generating a podcast out of data, and related utilities and classes. -[Unreleased]: https://github.com/tobinus/python-podgen/compare/v1.0.0...develop +[Unreleased]: https://github.com/tobinus/python-podgen/compare/v1.0.1...develop +[1.0.1]: https://github.com/tobinus/python-podgen/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/tobinus/python-podgen/compare/290045ac...v1.0.0 diff --git a/podgen/version.py b/podgen/version.py index 1748128..19840cf 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -14,7 +14,7 @@ from builtins import * 'Version of python-podgen represented as tuple' -version = (1, 0, '1b1') +version = (1, 0, 1) 'Version of python-podgen represented as string' diff --git a/setup.py b/setup.py index d843bfb..e5f3719 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ name = 'podgen', packages = ['podgen'], # Remember to update the version in podgen.version, too! - version = '1.0.1b1', + version = '1.0.1', description = 'Generating podcasts with Python should be easy!', author = 'Thorben W. S. Dahl', author_email = 'thorben@sjostrom.no', From 752c8c89514d85ac3ee52d9212409bc60dc85821 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 12 Oct 2019 17:06:12 +0200 Subject: [PATCH 171/200] Add new Apple Podcasts categories --- CHANGELOG.md | 17 ++ doc/api.rst | 2 + doc/api.warnings.rst | 2 + doc/usage_guide/podcasts.rst | 2 +- podgen/__init__.py | 3 +- podgen/__main__.py | 2 +- podgen/category.py | 184 +++++++++++++++++++++- podgen/episode.py | 2 +- podgen/media.py | 4 +- podgen/not_supported_by_itunes_warning.py | 29 ++-- podgen/podcast.py | 3 +- podgen/tests/test_category.py | 64 +++++++- podgen/warnings.py | 61 +++++++ 13 files changed, 337 insertions(+), 38 deletions(-) create mode 100644 doc/api.warnings.rst create mode 100644 podgen/warnings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4897ae..77a2675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added + +- Support for the [new Apple Podcast categories][category-new-2019] that were [added August 9th 2019][category-published-2019]. +- Documentation of the Warning classes defined by PodGen. + +[category-new-2019]: https://podnews.net/article/apple-changed-podcast-categories-2019 +[category-published-2019]: https://itunespartner.apple.com/podcasts/whats-new/100002598 + +### Changed + +- Using one of the old iTunes (sub)categories will now generate a `LegacyCategoryWarning`. + +### Deprecated + +- Importing `NotSupportedByItunesWarning` from `podgen.not_supported_by_itunes_warning`. Import from `podgen` instead. + ## [1.0.1] - 2019-10-12 ### Added diff --git a/doc/api.rst b/doc/api.rst index a535034..f2cca87 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -9,6 +9,7 @@ API Documentation podgen.Person podgen.Media podgen.Category + podgen.warnings podgen.util .. toctree:: @@ -20,4 +21,5 @@ API Documentation api.person api.media api.category + api.warnings api.util diff --git a/doc/api.warnings.rst b/doc/api.warnings.rst new file mode 100644 index 0000000..95452df --- /dev/null +++ b/doc/api.warnings.rst @@ -0,0 +1,2 @@ +.. automodule:: podgen.warnings + :members: diff --git a/doc/usage_guide/podcasts.rst b/doc/usage_guide/podcasts.rst index c2daf1c..9f4dca6 100644 --- a/doc/usage_guide/podcasts.rst +++ b/doc/usage_guide/podcasts.rst @@ -64,7 +64,7 @@ Commonly used p.language = "en-US" p.authors = [Person("John Doe", "editor@example.org")] p.feed_url = "https://example.com/feeds/podcast.rss" # URL of this feed - p.category = Category("Technology", "Podcasting") + p.category = Category("Music", "Music History") p.owner = p.authors[0] p.xslt = "https://example.com/feed/stylesheet.xsl" # URL of XSLT stylesheet diff --git a/podgen/__init__.py b/podgen/__init__.py index 13e87c6..371e136 100644 --- a/podgen/__init__.py +++ b/podgen/__init__.py @@ -18,6 +18,7 @@ from .episode import Episode from .media import Media from .person import Person -from .not_supported_by_itunes_warning import NotSupportedByItunesWarning +from .warnings import NotSupportedByItunesWarning, PodgenWarning, \ + LegacyCategoryWarning, NotRecommendedWarning from .category import Category from .util import htmlencode diff --git a/podgen/__main__.py b/podgen/__main__.py index 5168fa5..3d38c94 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -55,7 +55,7 @@ def main(): p.description = 'This is a cool feed!' p.language = 'de' p.feed_url = 'http://example.com/feeds/myfeed.rss' - p.category = Category('Technology', 'Podcasting') + p.category = Category('Leisure', 'Aviation') p.explicit = False p.complete = False p.new_feed_url = 'http://example.com/new-feed.rss' diff --git a/podgen/category.py b/podgen/category.py index 31054d6..3510ab5 100644 --- a/podgen/category.py +++ b/podgen/category.py @@ -12,13 +12,26 @@ from __future__ import absolute_import, division, print_function, unicode_literals from builtins import * +import warnings +from podgen.warnings import LegacyCategoryWarning + class Category(object): - """Immutable class representing an iTunes category. + """Immutable class representing an Apple Podcasts category. + + By using this class, you can be sure that the chosen category is a + valid category, that it is formatted correctly and you will be warned + when using an old category. See https://help.apple.com/itc/podcasts_connect/#/itc9267a2f12 for an overview of the available categories and their subcategories. + .. versionchanged:: 1.1.0 + Updated to reflect `the new categories `_ + as of August 9th 2019 and yield a + :class:`~podgen.warnings.LegacyCategoryWarning` when using one of the + old categories. + .. note:: The categories are case-insensitive, and you may escape ampersands. @@ -41,7 +54,7 @@ class Category(object): Video Games """ - _categories = { + _legacy_categories = { 'Arts': ['Design', 'Fashion & Beauty', 'Food', 'Literature', 'Performing Arts', 'Visual Arts'], 'Business': ['Business News', 'Careers', 'Investing', @@ -71,6 +84,134 @@ class Category(object): 'TV & Film': [] } + _categories = { + 'Arts': [ + 'Books', + 'Design', + 'Fashion & Beauty', + 'Food', + 'Performing Arts', + 'Visual Arts', + ], + 'Business': [ + 'Careers', + 'Entrepreneurship', + 'Investing', + 'Management', + 'Marketing', + 'Non-Profit', + ], + 'Comedy': [ + 'Comedy Interviews', + 'Improv', + 'Stand-up', + ], + 'Education': [ + 'Courses', + 'How To', + 'Language Learning', + 'Self-Improvement', + ], + 'Fiction': [ + 'Comedy Fiction', + 'Drama', + 'Science Fiction', + ], + 'Government': [], + 'History': [], + 'Health & Fitness': [ + 'Alternative Health', + 'Fitness', + 'Medicine', + 'Mental Health', + 'Nutrition', + 'Sexuality', + ], + 'Kids & Family': [ + 'Education for Kids', + 'Parenting', + 'Pets & Animals', + 'Stories for Kids', + ], + 'Leisure': [ + 'Animation & Manga', + 'Automotive', + 'Aviation', + 'Crafts', + 'Games', + 'Hobbies', + 'Home & Garden', + 'Video Games', + ], + 'Music': [ + 'Music Commentary', + 'Music History', + 'Music Interviews', + ], + 'News': [ + 'Business News', + 'Daily News', + 'Entertainment News', + 'News Commentary', + 'Politics', + 'Sports News', + 'Tech News', + ], + 'Religion & Spirituality': [ + 'Buddhism', + 'Christianity', + 'Hinduism', + 'Islam', + 'Judaism', + 'Religion', + 'Spirituality', + ], + 'Science': [ + 'Astronomy', + 'Chemistry', + 'Earth Sciences', + 'Life Sciences', + 'Mathematics', + 'Natural Sciences', + 'Nature', + 'Physics', + 'Social Sciences', + ], + 'Society & Culture': [ + 'Documentary', + 'Personal Journals', + 'Philosophy', + 'Places & Travel', + 'Relationships', + ], + 'Sports': [ + 'Baseball', + 'Basketball', + 'Cricket', + 'Fantasy Sports', + 'Football', + 'Golf', + 'Hockey', + 'Rugby', + 'Running', + 'Soccer', + 'Swimming', + 'Tennis', + 'Volleyball', + 'Wilderness', + 'Wrestling', + ], + 'Technology': [], + 'True Crime': [], + 'TV & Film': [ + 'After Shows', + 'Film History', + 'Film Interviews', + 'Film Reviews', + 'TV Reviews', + ], + } + def __init__(self, category, subcategory=None): """Create new Category object. See the class description of :class:´~podgen.category.Category`. @@ -82,23 +223,53 @@ def __init__(self, category, subcategory=None): """ if not category: raise TypeError("category must be provided, was \"%s\"" % category) + try: + canonical_category, canonical_subcategory = self._look_up_category( + category, + subcategory, + self._categories, + ) + except ValueError: + # Maybe this is a legacy category? + canonical_category, canonical_subcategory = self._look_up_category( + category, + subcategory, + self._legacy_categories, + ) + # Okay, it is, warn about this + warnings.warn( + 'The category ("%s", "%s") is a legacy category. Please switch ' + 'to one of the new Apple Podcast categories.' % + (canonical_category, canonical_subcategory), + category=LegacyCategoryWarning, + stacklevel=2 + ) + + self.__category = canonical_category + self.__subcategory = canonical_subcategory + + def _look_up_category( + self, + category, + subcategory, + available_categories + ): # Do a case-insensitive search for the category search_category = category.strip().replace("&", "&").lower() - for actual_category in self._categories: + for actual_category in available_categories: if actual_category.lower() == search_category: # We found it canonical_category = actual_category break else: # no break raise ValueError('Invalid category "%s"' % category) - self.__category = canonical_category # Do a case-insensitive search for the subcategory, if provided canonical_subcategory = None if subcategory is not None: search_subcategory = subcategory.strip().replace("&", "&")\ .lower() - for actual_subcategory in self._categories[canonical_category]: + for actual_subcategory in available_categories[canonical_category]: if actual_subcategory.lower() == search_subcategory: canonical_subcategory = actual_subcategory break @@ -106,7 +277,8 @@ def __init__(self, category, subcategory=None): raise ValueError('Invalid subcategory "%s" under category "%s"' % (subcategory, canonical_category)) - self.__subcategory = canonical_subcategory + return canonical_category, canonical_subcategory + @property def category(self): diff --git a/podgen/episode.py b/podgen/episode.py index 6ccd3b7..106ba47 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -20,7 +20,7 @@ import dateutil.parser import dateutil.tz -from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from podgen.warnings import NotSupportedByItunesWarning from podgen.util import formatRFC2822, listToHumanreadableStr from podgen.compat import string_types diff --git a/podgen/media.py b/podgen/media.py index 0a0a75d..8270cc0 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -23,7 +23,7 @@ from tinytag import TinyTag import requests -from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from podgen.warnings import NotSupportedByItunesWarning from podgen import version @@ -64,7 +64,7 @@ class Media(object): .. note:: - A warning called :class:`~podgen.NotSupportedByItunesWarning` + A warning called :class:`~podgen.warnings.NotSupportedByItunesWarning` will be issued if your URL or type isn't compatible with iTunes. See the Python documentation for more details on :mod:`warnings`. diff --git a/podgen/not_supported_by_itunes_warning.py b/podgen/not_supported_by_itunes_warning.py index 6480a78..2aac90a 100644 --- a/podgen/not_supported_by_itunes_warning.py +++ b/podgen/not_supported_by_itunes_warning.py @@ -1,18 +1,11 @@ -# -*- coding: utf-8 -*- -""" - podgen.not_supported_by_itunes_warning - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This file contains the NotSupportedByItunesWarning, which is used when the - library is used in a way that is not compatible with iTunes. - - :copyright: 2016, Thorben Dahl - :license: FreeBSD and LGPL, see license.* for more details. -""" -# Support for Python 2.7 -from __future__ import absolute_import, division, print_function, unicode_literals -from builtins import * - - -class NotSupportedByItunesWarning(UserWarning): - pass +# Kept for backwards compatibility +from podgen.warnings import NotSupportedByItunesWarning + +import warnings +warnings.warn( + "NotSupportedByItunesWarning should be imported from podgen.warnings, or " + "simply podgen. Support for importing from " + "podgen.not_supported_by_itunes_warning will be dropped in v2.0.0.", + category=DeprecationWarning, + stacklevel=2 +) diff --git a/podgen/podcast.py b/podgen/podcast.py index 2a0b8a7..06b2d76 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -19,7 +19,7 @@ import dateutil.parser import dateutil.tz from podgen.episode import Episode -from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from podgen.warnings import NotSupportedByItunesWarning from podgen.util import ensure_format, formatRFC2822, listToHumanreadableStr, \ htmlencode from podgen.person import Person @@ -1156,4 +1156,3 @@ def feed_url(self, feed_url): "doesn't have a valid URL scheme " "(like for example http:// or https://)") self.__feed_url = feed_url - diff --git a/podgen/tests/test_category.py b/podgen/tests/test_category.py index e80d7f0..7adb8db 100644 --- a/podgen/tests/test_category.py +++ b/podgen/tests/test_category.py @@ -13,11 +13,22 @@ from builtins import * import unittest +import warnings +import sys -from podgen import Category +from podgen import Category, LegacyCategoryWarning class TestCategory(unittest.TestCase): + # Ensure warning capturing works (only needed for Python 2.7 -- otherwise + # we could just have used assertWarns) + def setUp(self): + # The __warningregistry__'s need to be in a pristine state for tests + # to work properly. + for v in sys.modules.values(): + if getattr(v, '__warningregistry__', None): + v.__warningregistry__ = {} + def test_constructorWithSubcategory(self): c = Category("Arts", "Food") self.assertEqual(c.category, "Arts") @@ -44,13 +55,54 @@ def test_constructorCaseInsensitive(self): def test_immutable(self): c = Category("Arts", "Food") - self.assertRaises(AttributeError, setattr, c, "category", "Technology") + self.assertRaises(AttributeError, setattr, c, "category", "Fiction") self.assertEqual(c.category, "Arts") - self.assertRaises(AttributeError, setattr, c, "subcategory", "Design") + self.assertRaises(AttributeError, setattr, c, "subcategory", "Science Fiction") self.assertEqual(c.subcategory, "Food") def test_escapedIsAccepted(self): - c = Category("Sports & Recreation", "College & High School") - self.assertEqual(c.category, "Sports & Recreation") - self.assertEqual(c.subcategory, "College & High School") + c = Category("Kids & Family", "Pets & Animals") + self.assertEqual(c.category, "Kids & Family") + self.assertEqual(c.subcategory, "Pets & Animals") + + def test_oldCategoryIsAcceptedWithWarning(self): + # Replacement of assertWarns in Python 2.7 + with warnings.catch_warnings(record=True) as w: + # Replacement of assertWarns in Python 2.7 + warnings.simplefilter("always", LegacyCategoryWarning) + + c = Category("Government & Organizations") + self.assertEqual(c.category, "Government & Organizations") + + # Replacement of assertWarns in Python 2.7 + self.assertEqual(len(w), 1) + self.assertIsInstance(w[0].message, LegacyCategoryWarning) + + def test_oldSubcategoryIsAcceptedWithWarnings(self): + # Replacement of assertWarns in Python 2.7 + with warnings.catch_warnings(record=True) as w: + # Replacement of assertWarns in Python 2.7 + warnings.simplefilter("always", LegacyCategoryWarning) + + c = Category("Technology", "Podcasting") + self.assertEqual(c.category, "Technology") + self.assertEqual(c.subcategory, "Podcasting") + + # Replacement of assertWarns in Python 2.7 + self.assertEqual(len(w), 1) + self.assertIsInstance(w[0].message, LegacyCategoryWarning) + + def test_oldCategorySubcategoryIsAcceptedWithWarnings(self): + # Replacement of assertWarns in Python 2.7 + with warnings.catch_warnings(record=True) as w: + # Replacement of assertWarns in Python 2.7 + warnings.simplefilter("always", LegacyCategoryWarning) + + c = Category("Science & Medicine", "Medicine") + self.assertEqual(c.category, "Science & Medicine") + self.assertEqual(c.subcategory, "Medicine") + + # Replacement of assertWarns in Python 2.7 + self.assertEqual(len(w), 1) + self.assertIsInstance(w[0].message, LegacyCategoryWarning) diff --git a/podgen/warnings.py b/podgen/warnings.py new file mode 100644 index 0000000..9f3c3a3 --- /dev/null +++ b/podgen/warnings.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" + podgen.warnings + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This file contains PodGen-specific warnings. + They can be imported directly from ``podgen``. + + :copyright: 2019, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + + +class PodgenWarning(UserWarning): + """ + Superclass for all warnings defined by PodGen. + """ + pass + + +class NotRecommendedWarning(PodgenWarning): + """ + Warns against behaviour or usage which is usually discouraged. However, + there may exist exceptions where there is no better way. + """ + pass + + +class LegacyCategoryWarning(PodgenWarning): + """ + Indicates that the category created is an old category. It will still be + accepted by Apple Podcasts, but it would be wise to use the new categories + since they may have more relevant options for your podcast. + + .. seealso:: + + `What's New: Enhanced Apple Podcasts Categories `_ + Consequences of using old categories. + + `Podcasts Connect Help: Apple Podcasts categories `_ + Up-to-date list of available categories. + + `Podnews: New and changed Apple Podcasts categories `_ + List of changes between the old and the new categories. + """ + pass + + +class NotSupportedByItunesWarning(PodgenWarning): + """ + Indicates that PodGen is used in a way that may not be compatible with Apple + Podcasts (previously known as iTunes). + + In some cases, this may be because PodGen has not been kept up-to-date with + new features which Apple Podcasts has added support for. Please add an issue + if that is the case! + """ + pass From 6f84d8ca1733e7bbc1c3df99db6fc0c9c07e9032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Mon, 9 Dec 2019 17:37:11 +0100 Subject: [PATCH 172/200] Test no warning is raised for valid categories --- podgen/tests/test_category.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/podgen/tests/test_category.py b/podgen/tests/test_category.py index 7adb8db..9b2a7c6 100644 --- a/podgen/tests/test_category.py +++ b/podgen/tests/test_category.py @@ -30,9 +30,18 @@ def setUp(self): v.__warningregistry__ = {} def test_constructorWithSubcategory(self): - c = Category("Arts", "Food") - self.assertEqual(c.category, "Arts") - self.assertEqual(c.subcategory, "Food") + # Replacement of assertWarns in Python 2.7 + with warnings.catch_warnings(record=True) as w: + # Replacement of assertWarns in Python 2.7 + warnings.simplefilter("always", LegacyCategoryWarning) + + c = Category("Arts", "Food") + self.assertEqual(c.category, "Arts") + self.assertEqual(c.subcategory, "Food") + + # No warning should be given + # Replacement of assertWarns in Python 2.7 + self.assertEqual(len(w), 0); def test_constructorWithoutSubcategory(self): c = Category("Arts") From 230dd612be6d0e78e505f8fb62a698fb80f30a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Mon, 9 Dec 2019 19:28:26 +0100 Subject: [PATCH 173/200] Implement Episode.episode_type Fixes #103 --- podgen/__init__.py | 1 + podgen/constants.py | 3 +++ podgen/episode.py | 37 ++++++++++++++++++++++++++++++++++++ podgen/tests/test_episode.py | 31 +++++++++++++++++++++++++++++- 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 podgen/constants.py diff --git a/podgen/__init__.py b/podgen/__init__.py index 13e87c6..68731e2 100644 --- a/podgen/__init__.py +++ b/podgen/__init__.py @@ -14,6 +14,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals from builtins import * +from .constants import EPISODE_TYPE_BONUS, EPISODE_TYPE_FULL, EPISODE_TYPE_TRAILER from .podcast import Podcast from .episode import Episode from .media import Media diff --git a/podgen/constants.py b/podgen/constants.py new file mode 100644 index 0000000..10d3ea9 --- /dev/null +++ b/podgen/constants.py @@ -0,0 +1,3 @@ +EPISODE_TYPE_FULL = "full" +EPISODE_TYPE_TRAILER = "trailer" +EPISODE_TYPE_BONUS = "bonus" diff --git a/podgen/episode.py b/podgen/episode.py index 6ccd3b7..b7b741f 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -22,6 +22,7 @@ from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning from podgen.util import formatRFC2822, listToHumanreadableStr +from podgen.constants import EPISODE_TYPE_FULL, EPISODE_TYPE_TRAILER, EPISODE_TYPE_BONUS from podgen.compat import string_types @@ -174,6 +175,8 @@ def __init__(self, **kwargs): self.__position = None + self.__episode_type = EPISODE_TYPE_FULL + self.subtitle = None """A short subtitle. @@ -300,6 +303,13 @@ def rss_entry(self): '{%s}isClosedCaptioned' % ITUNES_NS) is_closed_captioned.text = 'Yes' + if self.__episode_type != EPISODE_TYPE_FULL: + episode_type = etree.SubElement( + entry, + '{%s}episodeType' % ITUNES_NS + ) + episode_type.text = self.__episode_type + if self.__position is not None and self.__position >= 0: order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) order.text = str(self.__position) @@ -532,6 +542,33 @@ def explicit(self, explicit): else: self.__explicit = None + @property + def episode_type(self): + """Indicate whether this is a full episode, or a bonus or trailer for + an episode or a season. + + By default, the episode is taken to be a full episode. + + Use the constants ``EPISODE_TYPE_FULL``, ``EPISODE_TYPE_TRAILER`` or + ``EPISODE_TYPE_BONUS`` (available to import from ``podgen``). + + :type: One of the three constants mentioned. + :RSS: itunes:episodeType + """ + return self.__episode_type + + @episode_type.setter + def episode_type(self, episode_type): + as_str = str(episode_type) + if (as_str not in ( + EPISODE_TYPE_FULL, + EPISODE_TYPE_BONUS, + EPISODE_TYPE_TRAILER, + )): + raise ValueError('Invalid episode_type value "%s"' % as_str) + + self.__episode_type = as_str + @property def position(self): """A custom position for this episode on the iTunes store page. diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index be4e503..df90241 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -18,7 +18,8 @@ from lxml import etree from podgen import Person, Media, Podcast, htmlencode, Episode, \ - NotSupportedByItunesWarning + NotSupportedByItunesWarning, EPISODE_TYPE_FULL, EPISODE_TYPE_BONUS, \ + EPISODE_TYPE_TRAILER import datetime import pytz from dateutil.parser import parse as parsedate @@ -512,6 +513,34 @@ def get_element(): assert get_element() is not None self.assertEqual(get_element().text.lower(), "yes") + def test_episodeType(self): + def get_element(): + return self.fe.rss_entry()\ + .find("{%s}episodeType" % self.itunes_ns) + + # Starts out as "full" + self.assertEqual(self.fe.episode_type, EPISODE_TYPE_FULL) + + # Not used when set to "full" + assert get_element() is None + + # Used when set to "trailer" + self.fe.episode_type = EPISODE_TYPE_TRAILER + assert get_element() is not None + self.assertEqual(get_element().text.lower(), "trailer") + + # Used when set to "bonus" + self.fe.episode_type = EPISODE_TYPE_BONUS + assert get_element() is not None + self.assertEqual(get_element().text.lower(), "bonus") + + # Fails when set to anything else + with self.assertRaises(ValueError): + self.fe.episode_type = "banana" + + with self.assertRaises(ValueError): + self.fe.episode_type = False + def test_link(self): def get_element(): return self.fe.rss_entry().find("link") From 1584b5ca7bb5abe22aa3baa3ec9bb2fc42254517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Mon, 9 Dec 2019 20:04:55 +0100 Subject: [PATCH 174/200] Implement Episode.season Fixes #102 --- CHANGELOG.md | 4 +++ podgen/__main__.py | 1 + podgen/episode.py | 35 ++++++++++++++++++++++ podgen/tests/test_episode.py | 56 ++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a2675..cf1b152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support for the [new Apple Podcast categories][category-new-2019] that were [added August 9th 2019][category-published-2019]. +- Support for `Episode.episode_type` for indicating whether an episode + constains a full episode, a trailer or bonus material. +- Support for `Episode.season` for indicating what season the episode belongs + to. - Documentation of the Warning classes defined by PodGen. [category-new-2019]: https://podnews.net/article/apple-changed-podcast-categories-2019 diff --git a/podgen/__main__.py b/podgen/__main__.py index 3d38c94..d898aec 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -65,6 +65,7 @@ def main(): e1 = p.add_episode() e1.id = 'http://lernfunk.de/_MEDIAID_123#1' e1.title = 'First Element' + e1.season = 1 e1.summary = htmlencode('''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 diff --git a/podgen/episode.py b/podgen/episode.py index bcd4ab6..f01823a 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -175,6 +175,8 @@ def __init__(self, **kwargs): self.__position = None + self.__season = None + self.__episode_type = EPISODE_TYPE_FULL self.subtitle = None @@ -310,6 +312,13 @@ def rss_entry(self): ) episode_type.text = self.__episode_type + if self.__season is not None: + season = etree.SubElement( + entry, + '{%s}season' % ITUNES_NS + ) + season.text = str(self.__season) + if self.__position is not None and self.__position >= 0: order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) order.text = str(self.__position) @@ -569,6 +578,32 @@ def episode_type(self, episode_type): self.__episode_type = as_str + @property + def season(self): + """The number of the season this episode belongs to. + + By default, the episode belongs to no season, indicated by this + attribute being set to :obj:`None`. + + Some podcast applications may choose to show season numbers only when + there is more than one season. + + :type: :obj:`None` or positive :obj:`int` + :RSS: itunes:season + """ + return self.__season + + @season.setter + def season(self, season): + if season is None: + self.__season = None + else: + as_int = int(season) + if as_int <= 0: + raise ValueError('Season number must be a positive, non-zero ' + 'integer; not "%s"' % as_int) + self.__season = as_int + @property def position(self): """A custom position for this episode on the iTunes store page. diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index df90241..c125e8f 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -513,6 +513,49 @@ def get_element(): assert get_element() is not None self.assertEqual(get_element().text.lower(), "yes") + def test_season(self): + def get_element(): + return self.fe.rss_entry()\ + .find("{%s}season" % self.itunes_ns) + + # Starts out as None + assert self.fe.season is None + + # Not used when set to None + assert get_element() is None + + # Can be set + self.fe.season = 3 + self.assertEqual(self.fe.season, 3) + + # Is used when set + assert get_element() is not None + self.assertEqual(get_element().text, "3") + + # Can be set to something that can be converted to int + self.fe.season = "5" + self.assertEqual(self.fe.season, 5) + assert get_element() is not None + self.assertEqual(get_element().text, "5") + + # Can be reset to None + self.fe.season = None + assert self.fe.season is None + assert get_element() is None + + # Gives error when set to something that cannot be converted to int + with self.assertRaises(ValueError): + self.fe.season = "Best Season" + + # Gives error when set to zero + with self.assertRaises(ValueError): + self.fe.season = 0 + + # Gives error when set to negative number + with self.assertRaises(ValueError): + self.fe.season = -1 + + def test_episodeType(self): def get_element(): return self.fe.rss_entry()\ @@ -526,14 +569,27 @@ def get_element(): # Used when set to "trailer" self.fe.episode_type = EPISODE_TYPE_TRAILER + self.assertEqual(self.fe.episode_type, EPISODE_TYPE_TRAILER) assert get_element() is not None self.assertEqual(get_element().text.lower(), "trailer") # Used when set to "bonus" self.fe.episode_type = EPISODE_TYPE_BONUS + self.assertEqual(self.fe.episode_type, EPISODE_TYPE_BONUS) assert get_element() is not None self.assertEqual(get_element().text.lower(), "bonus") + # Can be set to something that evaluates to "trailer" or "bonus" when + # converted to str() + class IsEpisodeTypeWhenStr: + def __str__(self): + return EPISODE_TYPE_TRAILER + + self.fe.episode_type = IsEpisodeTypeWhenStr() + self.assertEqual(self.fe.episode_type, EPISODE_TYPE_TRAILER) + assert get_element() is not None + self.assertEqual(get_element().text.lower(), "trailer") + # Fails when set to anything else with self.assertRaises(ValueError): self.fe.episode_type = "banana" From 34501eec58f243a287080f25d2a23d99ca6b438c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Tue, 28 Jan 2020 18:34:27 +0100 Subject: [PATCH 175/200] Ensure CI runs with newest pip version --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 3063649..e43d500 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,9 @@ python: install: # lxml dropped support for Python 3.4 in version 4.4.0 - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install 'lxml<4.4.0'; fi + # use newest version of pip to avoid e.g. selection of wrong dependency versions + - pip install -U pip + # install application development dependencies - pip install -r requirements.txt script: make test From a5f6b08b586a4e1cebde4847311a7c42a98c039b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Tue, 28 Jan 2020 18:40:58 +0100 Subject: [PATCH 176/200] Try solving 2.7 installation problem --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index e43d500..0078f0a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,8 +13,8 @@ python: install: # lxml dropped support for Python 3.4 in version 4.4.0 - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install 'lxml<4.4.0'; fi - # use newest version of pip to avoid e.g. selection of wrong dependency versions - - pip install -U pip + # in CI, incompatible zipp version is chosen for some reason + - if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then pip install 'lxml<2,>=0.5'; fi # install application development dependencies - pip install -r requirements.txt From 8aaae7b7357abc662047a465bacb9f7bb6b68294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Tue, 28 Jan 2020 18:43:00 +0100 Subject: [PATCH 177/200] Fix silly mistake --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 0078f0a..f683570 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ install: # lxml dropped support for Python 3.4 in version 4.4.0 - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install 'lxml<4.4.0'; fi # in CI, incompatible zipp version is chosen for some reason - - if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then pip install 'lxml<2,>=0.5'; fi + - if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then pip install 'zipp<2,>=0.5'; fi # install application development dependencies - pip install -r requirements.txt From a2866057a3a7436d88fda45990aee9dc5d4cce9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Tue, 28 Jan 2020 18:57:33 +0100 Subject: [PATCH 178/200] Attempt to trigger new build on CI From ded12f4b7751e4b95998c3ceba303e32aa7c79ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Tue, 28 Jan 2020 20:42:24 +0100 Subject: [PATCH 179/200] Add is_serial field, fixes #82 --- podgen/__main__.py | 1 + podgen/podcast.py | 33 +++++++++++++++++++++++++++++++++ podgen/tests/test_podcast.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/podgen/__main__.py b/podgen/__main__.py index d898aec..75934b8 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -53,6 +53,7 @@ def main(): p.website = 'http://example.com' p.copyright = 'cc-by' p.description = 'This is a cool feed!' + p.is_serial = True p.language = 'de' p.feed_url = 'http://example.com/feeds/myfeed.rss' p.category = Category('Leisure', 'Aviation') diff --git a/podgen/podcast.py b/podgen/podcast.py index 06b2d76..de8acfc 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -313,6 +313,35 @@ def __init__(self, **kwargs): .. _XSLT: https://en.wikipedia.org/wiki/XSLT """ + self.is_serial = False + """ + Flag indicating that the episodes should be consumed in order. + + Apple Podcasts operates with two types of podcasts. The default type, + episodic, is typically used for talkshows and other podcasts where + the listener can start with the latest episode with no problem. + The episode publication dates may be used to group the episodes. + + The other type is called "serial", and is used for podcasts where + the listener should start with the first episode – in the case of + seasons, they can start with the first episode of e.g. the latest + season. This is used for podcasts like "Serial", where the second + episode continues where the first episode left off. + + Set this to ``True`` to mark the podcast as serial. + Keep the default value, ``False``, to mark the podcast as + episodic. + + .. note:: + + To preserve backwards compatibility, no RSS element will be + produced when this is set to ``False``. This will be interpreted as + "episodic" by podcast applications. + + :type: :obj:`bool` + :RSS: itunes:type + """ + # Populate the podcast with the keyword arguments for attribute, value in iteritems(kwargs): if hasattr(self, attribute): @@ -451,6 +480,10 @@ def _create_rss(self): explicit = etree.SubElement(channel, '{%s}explicit' % ITUNES_NS) explicit.text = "yes" if self.explicit else "no" + if self.is_serial: + is_serial = etree.SubElement(channel, "{%s}type" % ITUNES_NS) + is_serial.text = "serial" + if self.__cloud: cloud = etree.SubElement(channel, 'cloud') cloud.attrib['domain'] = self.__cloud.get('domain') diff --git a/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py index c5573f6..fa81180 100644 --- a/podgen/tests/test_podcast.py +++ b/podgen/tests/test_podcast.py @@ -601,5 +601,39 @@ def test_imageNoWarningWithGoodExt(self): # Was the image set? self.assertEqual(good_ext, self.fg.image) + def test_isSerialDefaultFalse(self): + self.assertTrue( + hasattr(self.fg, "is_serial"), + "No is_serial attribute found on Podcast" + ) + self.assertFalse( + self.fg.is_serial, + "The is_serial attribute did not default to False" + ) + + def test_isSerialWhenFalse(self): + self.fg.is_serial = False + channel = self.fg._create_rss().find("channel") + podcast_type = channel.find("{%s}type" % self.nsItunes) + # When is_serial is False, we _could_ have set itunes:type to episodic. + # But rather than introduce a change to the generated RSS for those who + # upgrade, we will only include the new RSS tag if this field has been + # changed from its default. Episodic is the default either way. + assert podcast_type is None + + def test_isSerialWhenTrue(self): + self.fg.is_serial = True + + # Test that the field is set + self.assertTrue(self.fg.is_serial, "is_serial did not update") + + # Test that the tag is set + channel = self.fg._create_rss().find("channel") + podcast_type = channel.find("{%s}type" % self.nsItunes) + assert podcast_type is not None, "Could not find " + + # Test that its contents is correct + self.assertEqual(podcast_type.text, "serial") + if __name__ == '__main__': unittest.main() From 28db7d8d7bb3a157f5ef868b7c9c685745103026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Tue, 28 Jan 2020 20:42:39 +0100 Subject: [PATCH 180/200] Remove wrong = None annotations in documentation --- doc/conf.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 5a7126f..05ec535 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -4,6 +4,18 @@ # serve to show the default. import sys, os, time, codecs, re +from sphinx.ext.autodoc import ( + ClassLevelDocumenter, + InstanceAttributeDocumenter +) + +# Monkey-patch bug causing all instance attributes to be shown with None as +# default value. +# See https://github.com/sphinx-doc/sphinx/issues/2044#issuecomment-285888160 +def iad_add_directive_header(self, sig): + ClassLevelDocumenter.add_directive_header(self, sig) + +InstanceAttributeDocumenter.add_directive_header = iad_add_directive_header # 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 From 6beef760f0042c8e34795c024ef216e5f5c9d6d0 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Wed, 29 Jan 2020 21:31:10 +0100 Subject: [PATCH 181/200] Use new Travis location --- readme.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 1972c6b..bab07f3 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,7 @@ PodGen (forked from python-feedgen) =================================== -[![Build Status](https://travis-ci.org/tobinus/python-podgen.svg?branch=master)](https://travis-ci.org/tobinus/python-podgen) -[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) +[![Build Status](https://travis-ci.com/tobinus/python-podgen.svg?branch=master)](https://travis-ci.com/tobinus/python-podgen)[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) This module can be used to generate podcast feeds in RSS format, and is From 72d3cc95dcfe72ca01a26df98d8ce4accf236185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Wed, 29 Jan 2020 21:32:48 +0100 Subject: [PATCH 182/200] Expand documentation on is_serial --- doc/conf.py | 2 +- doc/usage_guide/index.rst | 2 ++ doc/usage_guide/podcasts.rst | 32 ++++++++++++++++++++++++++++++++ podgen/podcast.py | 8 ++++---- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 05ec535..3e8bdc9 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -57,7 +57,7 @@ def iad_add_directive_header(self, sig): # General information about the project. project = u'PodGen' -copyright = u'2014, Lars Kiesow. Modified work © 2019, Thorben Dahl' +copyright = u'2014, Lars Kiesow. Modified work © 2020, Thorben Dahl' # 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/usage_guide/index.rst b/doc/usage_guide/index.rst index 65964e3..f63d89a 100644 --- a/doc/usage_guide/index.rst +++ b/doc/usage_guide/index.rst @@ -2,6 +2,8 @@ Usage Guide =========== +This part of the manual provides a guided tour of the PodGen library. + .. toctree:: :maxdepth: 1 diff --git a/doc/usage_guide/podcasts.rst b/doc/usage_guide/podcasts.rst index 9f4dca6..8523e5c 100644 --- a/doc/usage_guide/podcasts.rst +++ b/doc/usage_guide/podcasts.rst @@ -48,6 +48,38 @@ A podcast's image is worth special attention:: Even though the image *technically* is optional, you won't reach people without it. + +The two types of podcasts +~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are two types of podcasts in the world (according to Apple Podcasts, anyway): + +* **Episodic** podcasts are podcasts whose episodes are meant to be consumed in any order. + New listeners will likely start with the newest episode. + This is the traditional type of podcast, with examples like "The Daily" and "The Joe Rogan Experience". + Each episode of an episodic podcast is largely self-contained, although there may be + recurring jokes and references to older episodes. + +* **Serial** podcasts are podcasts whose episodes must be consumed from beginning to end. + New listeners will likely start with the first episode of the current season. + This is a newer phenomenon, made popular by the appropriately titled podcast "Serial". + Each episode of a serial podcast starts off where the last episode ended, though the seasons + are independent from one another. + +If you don't do anything, PodGen will assume that your podcast is episodic. + +If your podcast is serial, you can set the :attr:`~podgen.Podcast.is_serial` attribute to :data:`True`, like this:: + + p.is_serial = True + +.. note:: + + When :attr:`~podgen.Podcast.is_serial` is set to :data:`True`, + all episodes must be given an episode number. + Additionally, it is recommended that you associate each episode with a season. + This is covered on the next page. + + Optional attributes ~~~~~~~~~~~~~~~~~~~ diff --git a/podgen/podcast.py b/podgen/podcast.py index de8acfc..ded0e90 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -328,15 +328,15 @@ def __init__(self, **kwargs): season. This is used for podcasts like "Serial", where the second episode continues where the first episode left off. - Set this to ``True`` to mark the podcast as serial. - Keep the default value, ``False``, to mark the podcast as + Set this to :data:`True` to mark the podcast as serial. + Keep the default value, :data:`False`, to mark the podcast as episodic. .. note:: To preserve backwards compatibility, no RSS element will be - produced when this is set to ``False``. This will be interpreted as - "episodic" by podcast applications. + produced when this is set to :data:`False`. This will be interpreted + as "episodic" by podcast applications. :type: :obj:`bool` :RSS: itunes:type From 61342ebb91a08b850f048e5cfc450fd241563c4d Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 23 Feb 2020 17:36:09 +0100 Subject: [PATCH 183/200] Improve documentation front page --- doc/index.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 89e1442..0b3ae90 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -3,7 +3,8 @@ PodGen ====== .. image:: https://travis-ci.org/tobinus/python-podgen.svg?branch=master - :target: https://travis-ci.org/tobinus/python-podgen + :target: https://travis-ci.com/tobinus/python-podgen.svg?branch=master + :alt: Continuous Integration (Travis CI) .. image:: https://readthedocs.org/projects/podgen/badge/?version=latest :target: http://podgen.readthedocs.io/en/latest/?badge=latest @@ -58,7 +59,7 @@ PodGen is compatible with Python 2.7 and 3.4+. made by Apple to their podcast standards since 2016. This includes the new and renamed categories, the ability to mark episodes with episode and season number, and the ability to mark the podcast as "serial". - It is a goal to implement those changes in a new release in 2019. + It is a goal to eventually implement those changes in a new release. Please refer to the :doc:`background/roadmap`. From 330c60d00ae9d4bebcc2c7db553a286bb68b3c00 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 23 Feb 2020 17:36:30 +0100 Subject: [PATCH 184/200] Mention is_serial in CHANGELOG --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1b152..0b9e1c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support for the [new Apple Podcast categories][category-new-2019] that were [added August 9th 2019][category-published-2019]. +- Support for indicating that the episodes should be consumed in order, + by setting `Podcast.is_serial` to `True`. - Support for `Episode.episode_type` for indicating whether an episode - constains a full episode, a trailer or bonus material. + contains a full episode, a trailer or bonus material. - Support for `Episode.season` for indicating what season the episode belongs to. - Documentation of the Warning classes defined by PodGen. From 06ce4cd0be53c9db47b834a1aa193df6ee609a16 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 23 Feb 2020 19:57:03 +0100 Subject: [PATCH 185/200] Implement episode_number, fixes #104 --- doc/usage_guide/episodes.rst | 31 +++++++++++++++++ doc/usage_guide/podcasts.rst | 7 ++-- podgen/__main__.py | 1 + podgen/episode.py | 39 ++++++++++++++++++++++ podgen/podcast.py | 18 ++++++++++ podgen/tests/test_episode.py | 65 ++++++++++++++++++++++++++++++------ 6 files changed, 148 insertions(+), 13 deletions(-) diff --git a/doc/usage_guide/episodes.rst b/doc/usage_guide/episodes.rst index 80717cb..4676333 100644 --- a/doc/usage_guide/episodes.rst +++ b/doc/usage_guide/episodes.rst @@ -191,6 +191,37 @@ That is, given the example above, the id of ``my_episode`` would be Read more about :attr:`the id attribute `. +Organization of episodes +^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, podcast applications will organize episodes by their publication +date, with the most recent episode at top. In addition to this, many publishers +number their episodes by including a number in the episode titles. +Some also divide their episodes into seasons. +Such titles may look like "S02E04 Example title", to take an example. + +Generally, podcast applications can provide a better presentation when the information is +*structured*, rather than mangled together in the episode titles. Apple +therefore introduced `new ways of specifying season and episode numbers`_ through +separate fields in mid 2017. Unfortunately, `not all podcast applications have +adopted the fields`_, but hopefully that will improve as more publishers use +the new fields. + +The :attr:`~podgen.Episode.season` and :attr:`~podgen.Episode.episode_number` +attributes are used to set this information:: + + my_episode.title = "Example title" + my_episode.season = 2 + my_episode.episode_number = 4 + +The ``episode_number`` attribute is mandatory for full episodes if the podcast +is marked as serial. Otherwise, they are just nice to have. + +.. _new ways of specifying season and episode numbers: https://podnews.net/article/episode-numbers-faq +.. _not all podcast applications have adopted the fields: https://podnews.net/article/episode-number-support-in-podcast-apps + + + Episode's publication date ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/usage_guide/podcasts.rst b/doc/usage_guide/podcasts.rst index 8523e5c..a4c4ace 100644 --- a/doc/usage_guide/podcasts.rst +++ b/doc/usage_guide/podcasts.rst @@ -75,9 +75,10 @@ If your podcast is serial, you can set the :attr:`~podgen.Podcast.is_serial` att .. note:: When :attr:`~podgen.Podcast.is_serial` is set to :data:`True`, - all episodes must be given an episode number. - Additionally, it is recommended that you associate each episode with a season. - This is covered on the next page. + all full episodes must be given an + :attr:`episode number `. Additionally, it is + recommended that you associate each episode with a season. This is covered on + the next page. Optional attributes diff --git a/podgen/__main__.py b/podgen/__main__.py index 75934b8..05371e9 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -67,6 +67,7 @@ def main(): e1.id = 'http://lernfunk.de/_MEDIAID_123#1' e1.title = 'First Element' e1.season = 1 + e1.episode_number = 1 e1.summary = htmlencode('''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 diff --git a/podgen/episode.py b/podgen/episode.py index f01823a..881fcb5 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -175,6 +175,8 @@ def __init__(self, **kwargs): self.__position = None + self.__episode_number = None + self.__season = None self.__episode_type = EPISODE_TYPE_FULL @@ -323,6 +325,11 @@ def rss_entry(self): order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) order.text = str(self.__position) + if self.__episode_number is not None: + episode_number = etree.SubElement(entry, '{%s}episode' % ITUNES_NS) + # Convert via int, since we stored the original as-is + episode_number.text = str(int(self.__episode_number)) + if self.subtitle: subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) subtitle.text = self.subtitle @@ -627,3 +634,35 @@ def position(self, position): self.__position = int(position) else: self.__position = None + + @property + def episode_number(self): + """This episode's number (within the season). + + This number is used to sort the episodes for + :attr:`serial podcasts <.Podcast.is_serial>`. It can also be displayed + to the user as the episode number. For + :attr:`full episodes `, the episode numbers + should be unique within each season. + + This is mandatory for full episodes of serial podcasts. + + :type: :obj:`None` or positive :obj:`int` + :RSS: itunes:episode + """ + return self.__episode_number + + @episode_number.setter + def episode_number(self, episode_number): + if episode_number is not None: + as_integer = int(episode_number) + if 0 < as_integer: + # Store original (not int), to avoid confusion when setting + self.__episode_number = episode_number + else: + raise ValueError( + 'episode_number must be a positive, non-zero integer; not ' + '"%s"' % episode_number + ) + else: + self.__episode_number = None diff --git a/podgen/podcast.py b/podgen/podcast.py index ded0e90..41e96a3 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -18,6 +18,8 @@ from datetime import datetime import dateutil.parser import dateutil.tz + +from podgen import EPISODE_TYPE_FULL from podgen.episode import Episode from podgen.warnings import NotSupportedByItunesWarning from podgen.util import ensure_format, formatRFC2822, listToHumanreadableStr, \ @@ -331,6 +333,9 @@ def __init__(self, **kwargs): Set this to :data:`True` to mark the podcast as serial. Keep the default value, :data:`False`, to mark the podcast as episodic. + + When set to :data:`True`, the :attr:`Episode.episode_number` attribute + is mandatory. .. note:: @@ -619,6 +624,19 @@ def _create_rss(self): link_to_hub.attrib['rel'] = 'hub' for entry in self.episodes: + # Do episode checks that depend on information from Podcast + episode_number_is_mandatory = ( + self.is_serial and + entry.episode_type == EPISODE_TYPE_FULL + ) + if episode_number_is_mandatory and entry.episode_number is None: + raise ValueError( + 'The episode_number attribute is mandatory for full ' + 'episodes that belong to a serial podcast; is set to None ' + 'for %r' % entry + ) + + # Generate and add the episode to the RSS item = entry.rss_entry() channel.append(item) diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index c125e8f..91eddda 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -51,11 +51,11 @@ def setUp(self): #Use also the list directly fe = Episode() fg.episodes.append(fe) - fe.id = 'http://lernfunk.de/media/654321/1' + fe.id = 'http://lernfunk.de/media/654321/2' fe.title = 'The Second Episode' fe = fg.add_episode() - fe.id = 'http://lernfunk.de/media/654321/1' + fe.id = 'http://lernfunk.de/media/654321/3' fe.title = 'The Third Episode' self.fg = fg @@ -86,6 +86,7 @@ def test_constructor(self): is_closed_captioned = False position = 3 withhold_from_itunes = True + episode_number = 4 ep = Episode( title=title, @@ -101,6 +102,7 @@ def test_constructor(self): is_closed_captioned=is_closed_captioned, position=position, withhold_from_itunes=withhold_from_itunes, + episode_number=episode_number, ) # Time to check if this works @@ -117,18 +119,17 @@ def test_constructor(self): self.assertEqual(ep.is_closed_captioned, is_closed_captioned) self.assertEqual(ep.position, position) self.assertEqual(ep.withhold_from_itunes, withhold_from_itunes) + self.assertEqual(ep.episode_number, episode_number) def test_constructorUnknownKeyword(self): self.assertRaises(TypeError, Episode, tittel="What is tittel") self.assertRaises(TypeError, Episode, "This is not a keyword") def test_checkItemNumbers(self): - fg = self.fg assert len(fg.episodes) == 3 def test_checkEntryContent(self): - fg = self.fg assert len(fg.episodes) is not None @@ -388,6 +389,50 @@ def test_position(self): itunes_order = self.fe.rss_entry().find("{%s}order" % self.itunes_ns) assert itunes_order is None + def test_episodeNumber(self): + # Don't appear if None (default) + assert self.fe.episode_number is None + assert self.fe.rss_entry().find("{%s}episode" % self.itunes_ns) is None + + # Appear with the right number when set + self.fe.episode_number = 1 + assert self.fe.episode_number == 1 + itunes_episode = self.fe.rss_entry()\ + .find("{%s}episode" % self.itunes_ns) + assert itunes_episode is not None + assert itunes_episode.text == "1" + + # Must be non-zero integer + self.assertRaises(ValueError, setattr, self.fe, "episode_number", 0) + self.assertRaises(ValueError, setattr, self.fe, "episode_number", -1) + self.assertRaises(ValueError, setattr, self.fe, "episode_number", "not a number") + assert self.fe.episode_number == 1 + + def test_episodeNumberMandatoryWhenSerial(self): + # Vary the first episode. Ensure the remaining two don't interfere + for i, episode in enumerate(self.fg.episodes[1:], start=2): + episode.episode_number = i + + # Test that the missing episode_number is reacted on + self.fg.is_serial = True + self.assertRaises((RuntimeError, ValueError), self.fg.rss_str) + + # Does not raise when the episode is a trailer + self.fe.episode_type = EPISODE_TYPE_TRAILER + self.fg.rss_str() + + # Does not raise when the episode is a bonus + self.fe.episode_type = EPISODE_TYPE_BONUS + self.fg.rss_str() + + # Still raises for full episode + self.fe.episode_type = EPISODE_TYPE_FULL + self.assertRaises((RuntimeError, ValueError), self.fg.rss_str) + + # Does not raise when the episode has a number + self.fe.episode_number = 1 + self.fg.rss_str() + def test_mandatoryAttributes(self): ep = Episode() self.assertRaises((RuntimeError, ValueError), ep.rss_entry) @@ -517,7 +562,7 @@ def test_season(self): def get_element(): return self.fe.rss_entry()\ .find("{%s}season" % self.itunes_ns) - + # Starts out as None assert self.fe.season is None @@ -550,20 +595,20 @@ def get_element(): # Gives error when set to zero with self.assertRaises(ValueError): self.fe.season = 0 - + # Gives error when set to negative number with self.assertRaises(ValueError): self.fe.season = -1 - + def test_episodeType(self): def get_element(): return self.fe.rss_entry()\ .find("{%s}episodeType" % self.itunes_ns) - + # Starts out as "full" self.assertEqual(self.fe.episode_type, EPISODE_TYPE_FULL) - + # Not used when set to "full" assert get_element() is None @@ -584,7 +629,7 @@ def get_element(): class IsEpisodeTypeWhenStr: def __str__(self): return EPISODE_TYPE_TRAILER - + self.fe.episode_type = IsEpisodeTypeWhenStr() self.assertEqual(self.fe.episode_type, EPISODE_TYPE_TRAILER) assert get_element() is not None From 0ef0052aa73fb2893ba3c571c69746b110da53c0 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 23 Feb 2020 20:45:35 +0100 Subject: [PATCH 186/200] Document episode_type in the usage guide --- doc/usage_guide/episodes.rst | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/doc/usage_guide/episodes.rst b/doc/usage_guide/episodes.rst index 4676333..2cfd307 100644 --- a/doc/usage_guide/episodes.rst +++ b/doc/usage_guide/episodes.rst @@ -284,6 +284,59 @@ You can even have multiple authors:: Read more about :attr:`an episode's authors `. +Bonuses and Trailers +^^^^^^^^^^^^^^^^^^^^ + +Sometimes, you may have some bonus material that did not make it into the +published episode, such as a 1-hour interview which was cut down to 10 minutes +for the podcast, or funny outtakes. Or, you may want to generate some hype for an upcoming season +of a podcast ahead of its first episode. + +Bonuses and trailers are added to the podcast the same way regular episodes are +added, but with the :attr:`~podgen.Episode.episode_type` attribute set to a +different value depending on if it is a bonus or a trailer. + +The following constants are used as values of ``episode_type``: + +* Bonus: ``EPISODE_TYPE_BONUS`` +* Trailer: ``EPISODE_TYPE_TRAILER`` +* Full/regular (default): ``EPISODE_TYPE_FULL`` + +The constants can be imported from ``podgen``. Here is an example:: + + from podgen import Podcast, EPISODE_TYPE_BONUS + + # Create the podcast + my_podcast = Podcast() + # Fill in the podcast details + # ... + + # Create the ordinary episode + my_episode = my_podcast.add_episode() + my_episode.title = "The history of Acme Industries" + my_episode.season = 1 + my_episode.episode_number = 9 + + # Create the bonus episode associated with the ordinary episode above + my_bonus = my_podcast.add_episode() + my_bonus.title = "Full interview with John Doe about Acme Industries" + my_bonus.episode_type = EPISODE_TYPE_BONUS + my_bonus.season = 1 + my_bonus.episode_number = 9 + # ... + +:attr:`~podgen.Episode.episode_type` combines with :attr:`~podgen.Episode.season` +and :attr:`~podgen.Episode.episode_number` to indicate what this is a bonus or trailer for. + +* If you specify an :attr:`episode number `, + optionally with a :attr:`season number ` if you divide episodes by season, + it will be a bonus or trailer for that episode. + You can see this in the example above. +* If you specify only a :attr:`~podgen.Episode.season`, then it will be a bonus or trailer for that season. +* If you specify none of those, + it will be a bonus or trailer for the podcast itself. + + Less used attributes ^^^^^^^^^^^^^^^^^^^^ From 941aa3cd06f9d7d60e9ff017db91264afe6d32a6 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sat, 12 Oct 2019 17:06:12 +0200 Subject: [PATCH 187/200] Add new Apple Podcasts categories --- CHANGELOG.md | 17 ++ doc/api.rst | 2 + doc/api.warnings.rst | 2 + doc/usage_guide/podcasts.rst | 2 +- podgen/__init__.py | 3 +- podgen/__main__.py | 2 +- podgen/category.py | 184 +++++++++++++++++++++- podgen/episode.py | 2 +- podgen/media.py | 4 +- podgen/not_supported_by_itunes_warning.py | 29 ++-- podgen/podcast.py | 3 +- podgen/tests/test_category.py | 64 +++++++- podgen/warnings.py | 61 +++++++ 13 files changed, 337 insertions(+), 38 deletions(-) create mode 100644 doc/api.warnings.rst create mode 100644 podgen/warnings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4897ae..77a2675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added + +- Support for the [new Apple Podcast categories][category-new-2019] that were [added August 9th 2019][category-published-2019]. +- Documentation of the Warning classes defined by PodGen. + +[category-new-2019]: https://podnews.net/article/apple-changed-podcast-categories-2019 +[category-published-2019]: https://itunespartner.apple.com/podcasts/whats-new/100002598 + +### Changed + +- Using one of the old iTunes (sub)categories will now generate a `LegacyCategoryWarning`. + +### Deprecated + +- Importing `NotSupportedByItunesWarning` from `podgen.not_supported_by_itunes_warning`. Import from `podgen` instead. + ## [1.0.1] - 2019-10-12 ### Added diff --git a/doc/api.rst b/doc/api.rst index a535034..f2cca87 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -9,6 +9,7 @@ API Documentation podgen.Person podgen.Media podgen.Category + podgen.warnings podgen.util .. toctree:: @@ -20,4 +21,5 @@ API Documentation api.person api.media api.category + api.warnings api.util diff --git a/doc/api.warnings.rst b/doc/api.warnings.rst new file mode 100644 index 0000000..95452df --- /dev/null +++ b/doc/api.warnings.rst @@ -0,0 +1,2 @@ +.. automodule:: podgen.warnings + :members: diff --git a/doc/usage_guide/podcasts.rst b/doc/usage_guide/podcasts.rst index c2daf1c..9f4dca6 100644 --- a/doc/usage_guide/podcasts.rst +++ b/doc/usage_guide/podcasts.rst @@ -64,7 +64,7 @@ Commonly used p.language = "en-US" p.authors = [Person("John Doe", "editor@example.org")] p.feed_url = "https://example.com/feeds/podcast.rss" # URL of this feed - p.category = Category("Technology", "Podcasting") + p.category = Category("Music", "Music History") p.owner = p.authors[0] p.xslt = "https://example.com/feed/stylesheet.xsl" # URL of XSLT stylesheet diff --git a/podgen/__init__.py b/podgen/__init__.py index 13e87c6..371e136 100644 --- a/podgen/__init__.py +++ b/podgen/__init__.py @@ -18,6 +18,7 @@ from .episode import Episode from .media import Media from .person import Person -from .not_supported_by_itunes_warning import NotSupportedByItunesWarning +from .warnings import NotSupportedByItunesWarning, PodgenWarning, \ + LegacyCategoryWarning, NotRecommendedWarning from .category import Category from .util import htmlencode diff --git a/podgen/__main__.py b/podgen/__main__.py index 5168fa5..3d38c94 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -55,7 +55,7 @@ def main(): p.description = 'This is a cool feed!' p.language = 'de' p.feed_url = 'http://example.com/feeds/myfeed.rss' - p.category = Category('Technology', 'Podcasting') + p.category = Category('Leisure', 'Aviation') p.explicit = False p.complete = False p.new_feed_url = 'http://example.com/new-feed.rss' diff --git a/podgen/category.py b/podgen/category.py index 31054d6..3510ab5 100644 --- a/podgen/category.py +++ b/podgen/category.py @@ -12,13 +12,26 @@ from __future__ import absolute_import, division, print_function, unicode_literals from builtins import * +import warnings +from podgen.warnings import LegacyCategoryWarning + class Category(object): - """Immutable class representing an iTunes category. + """Immutable class representing an Apple Podcasts category. + + By using this class, you can be sure that the chosen category is a + valid category, that it is formatted correctly and you will be warned + when using an old category. See https://help.apple.com/itc/podcasts_connect/#/itc9267a2f12 for an overview of the available categories and their subcategories. + .. versionchanged:: 1.1.0 + Updated to reflect `the new categories `_ + as of August 9th 2019 and yield a + :class:`~podgen.warnings.LegacyCategoryWarning` when using one of the + old categories. + .. note:: The categories are case-insensitive, and you may escape ampersands. @@ -41,7 +54,7 @@ class Category(object): Video Games """ - _categories = { + _legacy_categories = { 'Arts': ['Design', 'Fashion & Beauty', 'Food', 'Literature', 'Performing Arts', 'Visual Arts'], 'Business': ['Business News', 'Careers', 'Investing', @@ -71,6 +84,134 @@ class Category(object): 'TV & Film': [] } + _categories = { + 'Arts': [ + 'Books', + 'Design', + 'Fashion & Beauty', + 'Food', + 'Performing Arts', + 'Visual Arts', + ], + 'Business': [ + 'Careers', + 'Entrepreneurship', + 'Investing', + 'Management', + 'Marketing', + 'Non-Profit', + ], + 'Comedy': [ + 'Comedy Interviews', + 'Improv', + 'Stand-up', + ], + 'Education': [ + 'Courses', + 'How To', + 'Language Learning', + 'Self-Improvement', + ], + 'Fiction': [ + 'Comedy Fiction', + 'Drama', + 'Science Fiction', + ], + 'Government': [], + 'History': [], + 'Health & Fitness': [ + 'Alternative Health', + 'Fitness', + 'Medicine', + 'Mental Health', + 'Nutrition', + 'Sexuality', + ], + 'Kids & Family': [ + 'Education for Kids', + 'Parenting', + 'Pets & Animals', + 'Stories for Kids', + ], + 'Leisure': [ + 'Animation & Manga', + 'Automotive', + 'Aviation', + 'Crafts', + 'Games', + 'Hobbies', + 'Home & Garden', + 'Video Games', + ], + 'Music': [ + 'Music Commentary', + 'Music History', + 'Music Interviews', + ], + 'News': [ + 'Business News', + 'Daily News', + 'Entertainment News', + 'News Commentary', + 'Politics', + 'Sports News', + 'Tech News', + ], + 'Religion & Spirituality': [ + 'Buddhism', + 'Christianity', + 'Hinduism', + 'Islam', + 'Judaism', + 'Religion', + 'Spirituality', + ], + 'Science': [ + 'Astronomy', + 'Chemistry', + 'Earth Sciences', + 'Life Sciences', + 'Mathematics', + 'Natural Sciences', + 'Nature', + 'Physics', + 'Social Sciences', + ], + 'Society & Culture': [ + 'Documentary', + 'Personal Journals', + 'Philosophy', + 'Places & Travel', + 'Relationships', + ], + 'Sports': [ + 'Baseball', + 'Basketball', + 'Cricket', + 'Fantasy Sports', + 'Football', + 'Golf', + 'Hockey', + 'Rugby', + 'Running', + 'Soccer', + 'Swimming', + 'Tennis', + 'Volleyball', + 'Wilderness', + 'Wrestling', + ], + 'Technology': [], + 'True Crime': [], + 'TV & Film': [ + 'After Shows', + 'Film History', + 'Film Interviews', + 'Film Reviews', + 'TV Reviews', + ], + } + def __init__(self, category, subcategory=None): """Create new Category object. See the class description of :class:´~podgen.category.Category`. @@ -82,23 +223,53 @@ def __init__(self, category, subcategory=None): """ if not category: raise TypeError("category must be provided, was \"%s\"" % category) + try: + canonical_category, canonical_subcategory = self._look_up_category( + category, + subcategory, + self._categories, + ) + except ValueError: + # Maybe this is a legacy category? + canonical_category, canonical_subcategory = self._look_up_category( + category, + subcategory, + self._legacy_categories, + ) + # Okay, it is, warn about this + warnings.warn( + 'The category ("%s", "%s") is a legacy category. Please switch ' + 'to one of the new Apple Podcast categories.' % + (canonical_category, canonical_subcategory), + category=LegacyCategoryWarning, + stacklevel=2 + ) + + self.__category = canonical_category + self.__subcategory = canonical_subcategory + + def _look_up_category( + self, + category, + subcategory, + available_categories + ): # Do a case-insensitive search for the category search_category = category.strip().replace("&", "&").lower() - for actual_category in self._categories: + for actual_category in available_categories: if actual_category.lower() == search_category: # We found it canonical_category = actual_category break else: # no break raise ValueError('Invalid category "%s"' % category) - self.__category = canonical_category # Do a case-insensitive search for the subcategory, if provided canonical_subcategory = None if subcategory is not None: search_subcategory = subcategory.strip().replace("&", "&")\ .lower() - for actual_subcategory in self._categories[canonical_category]: + for actual_subcategory in available_categories[canonical_category]: if actual_subcategory.lower() == search_subcategory: canonical_subcategory = actual_subcategory break @@ -106,7 +277,8 @@ def __init__(self, category, subcategory=None): raise ValueError('Invalid subcategory "%s" under category "%s"' % (subcategory, canonical_category)) - self.__subcategory = canonical_subcategory + return canonical_category, canonical_subcategory + @property def category(self): diff --git a/podgen/episode.py b/podgen/episode.py index 6ccd3b7..106ba47 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -20,7 +20,7 @@ import dateutil.parser import dateutil.tz -from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from podgen.warnings import NotSupportedByItunesWarning from podgen.util import formatRFC2822, listToHumanreadableStr from podgen.compat import string_types diff --git a/podgen/media.py b/podgen/media.py index 0a0a75d..8270cc0 100644 --- a/podgen/media.py +++ b/podgen/media.py @@ -23,7 +23,7 @@ from tinytag import TinyTag import requests -from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from podgen.warnings import NotSupportedByItunesWarning from podgen import version @@ -64,7 +64,7 @@ class Media(object): .. note:: - A warning called :class:`~podgen.NotSupportedByItunesWarning` + A warning called :class:`~podgen.warnings.NotSupportedByItunesWarning` will be issued if your URL or type isn't compatible with iTunes. See the Python documentation for more details on :mod:`warnings`. diff --git a/podgen/not_supported_by_itunes_warning.py b/podgen/not_supported_by_itunes_warning.py index 6480a78..2aac90a 100644 --- a/podgen/not_supported_by_itunes_warning.py +++ b/podgen/not_supported_by_itunes_warning.py @@ -1,18 +1,11 @@ -# -*- coding: utf-8 -*- -""" - podgen.not_supported_by_itunes_warning - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This file contains the NotSupportedByItunesWarning, which is used when the - library is used in a way that is not compatible with iTunes. - - :copyright: 2016, Thorben Dahl - :license: FreeBSD and LGPL, see license.* for more details. -""" -# Support for Python 2.7 -from __future__ import absolute_import, division, print_function, unicode_literals -from builtins import * - - -class NotSupportedByItunesWarning(UserWarning): - pass +# Kept for backwards compatibility +from podgen.warnings import NotSupportedByItunesWarning + +import warnings +warnings.warn( + "NotSupportedByItunesWarning should be imported from podgen.warnings, or " + "simply podgen. Support for importing from " + "podgen.not_supported_by_itunes_warning will be dropped in v2.0.0.", + category=DeprecationWarning, + stacklevel=2 +) diff --git a/podgen/podcast.py b/podgen/podcast.py index 2a0b8a7..06b2d76 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -19,7 +19,7 @@ import dateutil.parser import dateutil.tz from podgen.episode import Episode -from podgen.not_supported_by_itunes_warning import NotSupportedByItunesWarning +from podgen.warnings import NotSupportedByItunesWarning from podgen.util import ensure_format, formatRFC2822, listToHumanreadableStr, \ htmlencode from podgen.person import Person @@ -1156,4 +1156,3 @@ def feed_url(self, feed_url): "doesn't have a valid URL scheme " "(like for example http:// or https://)") self.__feed_url = feed_url - diff --git a/podgen/tests/test_category.py b/podgen/tests/test_category.py index e80d7f0..7adb8db 100644 --- a/podgen/tests/test_category.py +++ b/podgen/tests/test_category.py @@ -13,11 +13,22 @@ from builtins import * import unittest +import warnings +import sys -from podgen import Category +from podgen import Category, LegacyCategoryWarning class TestCategory(unittest.TestCase): + # Ensure warning capturing works (only needed for Python 2.7 -- otherwise + # we could just have used assertWarns) + def setUp(self): + # The __warningregistry__'s need to be in a pristine state for tests + # to work properly. + for v in sys.modules.values(): + if getattr(v, '__warningregistry__', None): + v.__warningregistry__ = {} + def test_constructorWithSubcategory(self): c = Category("Arts", "Food") self.assertEqual(c.category, "Arts") @@ -44,13 +55,54 @@ def test_constructorCaseInsensitive(self): def test_immutable(self): c = Category("Arts", "Food") - self.assertRaises(AttributeError, setattr, c, "category", "Technology") + self.assertRaises(AttributeError, setattr, c, "category", "Fiction") self.assertEqual(c.category, "Arts") - self.assertRaises(AttributeError, setattr, c, "subcategory", "Design") + self.assertRaises(AttributeError, setattr, c, "subcategory", "Science Fiction") self.assertEqual(c.subcategory, "Food") def test_escapedIsAccepted(self): - c = Category("Sports & Recreation", "College & High School") - self.assertEqual(c.category, "Sports & Recreation") - self.assertEqual(c.subcategory, "College & High School") + c = Category("Kids & Family", "Pets & Animals") + self.assertEqual(c.category, "Kids & Family") + self.assertEqual(c.subcategory, "Pets & Animals") + + def test_oldCategoryIsAcceptedWithWarning(self): + # Replacement of assertWarns in Python 2.7 + with warnings.catch_warnings(record=True) as w: + # Replacement of assertWarns in Python 2.7 + warnings.simplefilter("always", LegacyCategoryWarning) + + c = Category("Government & Organizations") + self.assertEqual(c.category, "Government & Organizations") + + # Replacement of assertWarns in Python 2.7 + self.assertEqual(len(w), 1) + self.assertIsInstance(w[0].message, LegacyCategoryWarning) + + def test_oldSubcategoryIsAcceptedWithWarnings(self): + # Replacement of assertWarns in Python 2.7 + with warnings.catch_warnings(record=True) as w: + # Replacement of assertWarns in Python 2.7 + warnings.simplefilter("always", LegacyCategoryWarning) + + c = Category("Technology", "Podcasting") + self.assertEqual(c.category, "Technology") + self.assertEqual(c.subcategory, "Podcasting") + + # Replacement of assertWarns in Python 2.7 + self.assertEqual(len(w), 1) + self.assertIsInstance(w[0].message, LegacyCategoryWarning) + + def test_oldCategorySubcategoryIsAcceptedWithWarnings(self): + # Replacement of assertWarns in Python 2.7 + with warnings.catch_warnings(record=True) as w: + # Replacement of assertWarns in Python 2.7 + warnings.simplefilter("always", LegacyCategoryWarning) + + c = Category("Science & Medicine", "Medicine") + self.assertEqual(c.category, "Science & Medicine") + self.assertEqual(c.subcategory, "Medicine") + + # Replacement of assertWarns in Python 2.7 + self.assertEqual(len(w), 1) + self.assertIsInstance(w[0].message, LegacyCategoryWarning) diff --git a/podgen/warnings.py b/podgen/warnings.py new file mode 100644 index 0000000..9f3c3a3 --- /dev/null +++ b/podgen/warnings.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" + podgen.warnings + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This file contains PodGen-specific warnings. + They can be imported directly from ``podgen``. + + :copyright: 2019, Thorben Dahl + :license: FreeBSD and LGPL, see license.* for more details. +""" +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + + +class PodgenWarning(UserWarning): + """ + Superclass for all warnings defined by PodGen. + """ + pass + + +class NotRecommendedWarning(PodgenWarning): + """ + Warns against behaviour or usage which is usually discouraged. However, + there may exist exceptions where there is no better way. + """ + pass + + +class LegacyCategoryWarning(PodgenWarning): + """ + Indicates that the category created is an old category. It will still be + accepted by Apple Podcasts, but it would be wise to use the new categories + since they may have more relevant options for your podcast. + + .. seealso:: + + `What's New: Enhanced Apple Podcasts Categories `_ + Consequences of using old categories. + + `Podcasts Connect Help: Apple Podcasts categories `_ + Up-to-date list of available categories. + + `Podnews: New and changed Apple Podcasts categories `_ + List of changes between the old and the new categories. + """ + pass + + +class NotSupportedByItunesWarning(PodgenWarning): + """ + Indicates that PodGen is used in a way that may not be compatible with Apple + Podcasts (previously known as iTunes). + + In some cases, this may be because PodGen has not been kept up-to-date with + new features which Apple Podcasts has added support for. Please add an issue + if that is the case! + """ + pass From 2c559e24c68c6e89c47e753efc445031db075182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Mon, 9 Dec 2019 17:37:11 +0100 Subject: [PATCH 188/200] Test no warning is raised for valid categories --- podgen/tests/test_category.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/podgen/tests/test_category.py b/podgen/tests/test_category.py index 7adb8db..9b2a7c6 100644 --- a/podgen/tests/test_category.py +++ b/podgen/tests/test_category.py @@ -30,9 +30,18 @@ def setUp(self): v.__warningregistry__ = {} def test_constructorWithSubcategory(self): - c = Category("Arts", "Food") - self.assertEqual(c.category, "Arts") - self.assertEqual(c.subcategory, "Food") + # Replacement of assertWarns in Python 2.7 + with warnings.catch_warnings(record=True) as w: + # Replacement of assertWarns in Python 2.7 + warnings.simplefilter("always", LegacyCategoryWarning) + + c = Category("Arts", "Food") + self.assertEqual(c.category, "Arts") + self.assertEqual(c.subcategory, "Food") + + # No warning should be given + # Replacement of assertWarns in Python 2.7 + self.assertEqual(len(w), 0); def test_constructorWithoutSubcategory(self): c = Category("Arts") From 2fbfcf65289aadb93e34e99b0df1de131cf22194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Fri, 6 Mar 2020 18:00:42 +0100 Subject: [PATCH 189/200] Direct people to importing from podgen rather than submodules --- podgen/not_supported_by_itunes_warning.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/podgen/not_supported_by_itunes_warning.py b/podgen/not_supported_by_itunes_warning.py index 2aac90a..e21c539 100644 --- a/podgen/not_supported_by_itunes_warning.py +++ b/podgen/not_supported_by_itunes_warning.py @@ -3,9 +3,9 @@ import warnings warnings.warn( - "NotSupportedByItunesWarning should be imported from podgen.warnings, or " - "simply podgen. Support for importing from " - "podgen.not_supported_by_itunes_warning will be dropped in v2.0.0.", + "NotSupportedByItunesWarning should be imported from podgen. Support for " + "importing from podgen.not_supported_by_itunes_warning will be dropped in " + "v2.0.0.", category=DeprecationWarning, stacklevel=2 ) From 8f0a9edbeec5f2ef9088cd2893c71a12f26c608b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Fri, 6 Mar 2020 18:02:08 +0100 Subject: [PATCH 190/200] Make docs consistent with updated categories --- doc/background/roadmap.rst | 8 ++++---- doc/conf.py | 2 +- doc/index.rst | 8 ++++---- podgen/category.py | 1 - readme.md | 2 +- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/doc/background/roadmap.rst b/doc/background/roadmap.rst index 984c38d..fa2a935 100644 --- a/doc/background/roadmap.rst +++ b/doc/background/roadmap.rst @@ -11,12 +11,12 @@ are out there. The current plan for PodGen updates is as follows: -* **New minor version**: Support for the new Apple Podcast specifications and - categories, as much as is possible without breaking backwards compatibility. +* **New minor version**: Support for the new Apple Podcast specifications, + as much as is possible without breaking backwards compatibility. * Deprecation warnings for properties and such which will be removed in the next major version. -* **New major version**: Support for the new Apple Podcast specifications and - categories which could not be included earlier due to backwards compatibility, +* **New major version**: Support for the new Apple Podcast specifications which + could not be included earlier due to backwards compatibility, or which have new names or behaviours to simplify the API. Removal of deprecated features. * **New minor versions**: Removal of support for Python releases that have diff --git a/doc/conf.py b/doc/conf.py index 5a7126f..4450eb5 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -45,7 +45,7 @@ # General information about the project. project = u'PodGen' -copyright = u'2014, Lars Kiesow. Modified work © 2019, Thorben Dahl' +copyright = u'2014, Lars Kiesow. Modified work © 2020, Thorben Dahl' # 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/index.rst b/doc/index.rst index 89e1442..55377bb 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -54,11 +54,11 @@ PodGen is compatible with Python 2.7 and 3.4+. .. warning:: - As of October 7th 2019 (v1.0.1), PodGen does not support the additions and changes - made by Apple to their podcast standards since 2016. This includes - the new and renamed categories, the ability to mark episodes with episode and + As of March 6th 2020 (v1.1.0), PodGen does not support the additions and changes + made by Apple to their podcast standards since 2016, with the exception of the 2019 categories. + This includes the ability to mark episodes with episode and season number, and the ability to mark the podcast as "serial". - It is a goal to implement those changes in a new release in 2019. + It is a goal to implement those changes in a new release. Please refer to the :doc:`background/roadmap`. diff --git a/podgen/category.py b/podgen/category.py index 3510ab5..883c9bd 100644 --- a/podgen/category.py +++ b/podgen/category.py @@ -279,7 +279,6 @@ def _look_up_category( return canonical_category, canonical_subcategory - @property def category(self): """The category represented by this object. Read-only. diff --git a/readme.md b/readme.md index 1972c6b..aaf64da 100644 --- a/readme.md +++ b/readme.md @@ -26,7 +26,7 @@ Known bugs and limitations -------------------------- * The updates to Apple's podcasting guidelines since 2016 have not been - implemented. This includes the new categories, the ability to mark episodes + implemented. This includes the ability to mark episodes with episode and season number, and the ability to mark the podcast as "serial". It is a goal to implement those changes in a future release. * We do not follow the RSS recommendation to encode &, < and > using From 358268ac99faa7e184cfb0c29086deca97961720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Fri, 6 Mar 2020 18:03:20 +0100 Subject: [PATCH 191/200] Update version to 1.1.0-rc.1 --- CHANGELOG.md | 6 +++++- podgen/version.py | 2 +- setup.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a2675..93070a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + + +## [1.1.0] - 2020-03-06 ### Added - Support for the [new Apple Podcast categories][category-new-2019] that were [added August 9th 2019][category-published-2019]. @@ -51,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Podcast and Episode classes for easily generating a podcast out of data, and related utilities and classes. -[Unreleased]: https://github.com/tobinus/python-podgen/compare/v1.0.1...develop +[Unreleased]: https://github.com/tobinus/python-podgen/compare/v1.1.0...develop +[1.1.0]: https://github.com/tobinus/python-podgen/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/tobinus/python-podgen/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/tobinus/python-podgen/compare/290045ac...v1.0.0 diff --git a/podgen/version.py b/podgen/version.py index 19840cf..d99954b 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -14,7 +14,7 @@ from builtins import * 'Version of python-podgen represented as tuple' -version = (1, 0, 1) +version = (1, 1, "0-rc", 1) 'Version of python-podgen represented as string' diff --git a/setup.py b/setup.py index e5f3719..390aee0 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ name = 'podgen', packages = ['podgen'], # Remember to update the version in podgen.version, too! - version = '1.0.1', + version = '1.1.0-rc.1', description = 'Generating podcasts with Python should be easy!', author = 'Thorben W. S. Dahl', author_email = 'thorben@sjostrom.no', From 40b768c05aff984ae377a571a7f422aa602d3abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Fri, 6 Mar 2020 19:22:58 +0100 Subject: [PATCH 192/200] Ensure Travis runs with up-to-date setuptools --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index f683570..77cfeb6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ python: - "3.7" install: + - pip install -U setuptools # lxml dropped support for Python 3.4 in version 4.4.0 - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install 'lxml<4.4.0'; fi # in CI, incompatible zipp version is chosen for some reason From 22c473718183fe3f743a4e151317fd9e2418fabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Fri, 6 Mar 2020 20:42:08 +0100 Subject: [PATCH 193/200] Update Makefile with commands for publishing new version --- Makefile | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 4bf334f..fe5ab5d 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ -# Modified work Copyright 2016, Thorben Dahl +# Modified work Copyright 2020, Thorben Dahl # See license.* for more details -sdist: doc +sdist: python setup.py sdist +wheel: + python setup.py bdist_wheel --universal + clean: doc-clean @echo Removing binary files... @rm -f `find podgen -name '*.pyc'` @@ -12,7 +15,7 @@ clean: doc-clean @rm -rf dist/ @rm -f MANIFEST -doc: doc-clean doc-html doc-man doc-latexpdf +doc: doc-clean doc-html doc-clean: @echo Removing docs... @@ -26,6 +29,7 @@ doc-html: @echo 'Copying html to into docs dir' @cp -T -r doc/_build/html/ docs/html/ +# Not supported doc-man: @echo 'Generating manpage' @make -C doc man @@ -33,6 +37,7 @@ doc-man: @echo 'Copying manpage to into docs dir' @cp doc/_build/man/*.1 docs/man/ +# Not supported doc-latexpdf: @echo 'Generating pdf' @make -C doc latexpdf @@ -40,8 +45,8 @@ 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: sdist wheel + twine upload dist/* test: @python -m unittest podgen.tests.test_podcast podgen.tests.test_episode \ From c7d5dd24f48dbe7707b72c33ed9c8f5ead0044be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Fri, 6 Mar 2020 20:43:50 +0100 Subject: [PATCH 194/200] Release 1.1.0 proper --- podgen/version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/podgen/version.py b/podgen/version.py index d99954b..3786011 100644 --- a/podgen/version.py +++ b/podgen/version.py @@ -14,7 +14,7 @@ from builtins import * 'Version of python-podgen represented as tuple' -version = (1, 1, "0-rc", 1) +version = (1, 1, 0) 'Version of python-podgen represented as string' diff --git a/setup.py b/setup.py index 390aee0..1eef569 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ name = 'podgen', packages = ['podgen'], # Remember to update the version in podgen.version, too! - version = '1.1.0-rc.1', + version = '1.1.0', description = 'Generating podcasts with Python should be easy!', author = 'Thorben W. S. Dahl', author_email = 'thorben@sjostrom.no', From d8f1353b7d49d6b33c19950e81f86e10085d119e Mon Sep 17 00:00:00 2001 From: Christos Alexiou Date: Sun, 26 Jun 2022 14:41:24 +0300 Subject: [PATCH 195/200] feat: Deprecate travis, introduce GHub actions --- .github/workflows/ci.yaml | 33 +++++++++++++++++++++++++++++++++ .travis.yml | 22 ---------------------- 2 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/ci.yaml delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..cc2f59b --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,33 @@ +name: Build Podgen +on: + push: + branches: + - develop + - switch-to-github-actions + +jobs: + setup-test: + name: Setup and Test + runs-on: ubuntu-18.04 + environment: dev + strategy: + matrix: + python-version: ["2.7", "3.4", "3.5", "3.6", "3.7"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -U setuptools + if [[ ${{ matrix.python-version }} == 3.4 ]]; then pip install 'lxml<4.4.0'; fi + if [[ ${{ matrix.python-version }} == 2.7 ]]; then pip install 'zipp<2,>=0.5'; fi + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test + run: | + make test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 77cfeb6..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python - -sudo: required -dist: xenial - -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - -install: - - pip install -U setuptools - # lxml dropped support for Python 3.4 in version 4.4.0 - - if [[ $TRAVIS_PYTHON_VERSION == 3.4 ]]; then pip install 'lxml<4.4.0'; fi - # in CI, incompatible zipp version is chosen for some reason - - if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then pip install 'zipp<2,>=0.5'; fi - # install application development dependencies - - pip install -r requirements.txt - -script: make test From 521e3547dda92707051da46b30fb1a8575c9560b Mon Sep 17 00:00:00 2001 From: Christos Alexiou Date: Sun, 26 Jun 2022 14:48:35 +0300 Subject: [PATCH 196/200] feat: Add README badge --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 7bf9cde..0d7eab7 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ PodGen (forked from python-feedgen) =================================== -[![Build Status](https://travis-ci.com/tobinus/python-podgen.svg?branch=master)](https://travis-ci.com/tobinus/python-podgen)[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) +![Build Status](https://github.com/pcnoic/python-podgen/actions/workflows/ci.yaml/badge.svg)[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) This module can be used to generate podcast feeds in RSS format, and is From d7fc93dc86d19917026bfaf3eecc5b858f6deff2 Mon Sep 17 00:00:00 2001 From: Christos Alexiou Date: Sun, 26 Jun 2022 14:50:50 +0300 Subject: [PATCH 197/200] fix: Adapt userspace --- .github/workflows/ci.yaml | 1 - readme.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cc2f59b..7480759 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,7 +3,6 @@ on: push: branches: - develop - - switch-to-github-actions jobs: setup-test: diff --git a/readme.md b/readme.md index 0d7eab7..b41d0a3 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ PodGen (forked from python-feedgen) =================================== -![Build Status](https://github.com/pcnoic/python-podgen/actions/workflows/ci.yaml/badge.svg)[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) +![Build Status](https://github.com/tobinus/python-podgen/actions/workflows/ci.yaml/badge.svg)[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) This module can be used to generate podcast feeds in RSS format, and is From a2808049c392dff0e99fddf91e0a7c8e8c37c262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Sun, 1 Jan 2023 15:41:42 +0100 Subject: [PATCH 198/200] Run CI workflow for every push --- .github/workflows/{ci.yaml => run-tests.yaml} | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) rename .github/workflows/{ci.yaml => run-tests.yaml} (78%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/run-tests.yaml similarity index 78% rename from .github/workflows/ci.yaml rename to .github/workflows/run-tests.yaml index 7480759..8e2b67c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/run-tests.yaml @@ -1,14 +1,12 @@ -name: Build Podgen +name: Run tests on: - push: - branches: - - develop + push: {} + workflow_call: {} jobs: setup-test: - name: Setup and Test - runs-on: ubuntu-18.04 - environment: dev + name: Install and test in Python ${{ matrix.python-version }} + runs-on: ubuntu-22.04 strategy: matrix: python-version: ["2.7", "3.4", "3.5", "3.6", "3.7"] @@ -16,10 +14,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - - name: Setup up Python ${{ matrix.python-version }} + + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -27,6 +27,7 @@ jobs: if [[ ${{ matrix.python-version }} == 3.4 ]]; then pip install 'lxml<4.4.0'; fi if [[ ${{ matrix.python-version }} == 2.7 ]]; then pip install 'zipp<2,>=0.5'; fi if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test run: | make test From 45e6a8d4c95cbfad5568ae3780dd5724f31a6acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Sun, 1 Jan 2023 15:46:11 +0100 Subject: [PATCH 199/200] Update badges --- doc/index.rst | 6 +++--- readme.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index e959807..958a167 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -2,9 +2,9 @@ PodGen ====== -.. image:: https://travis-ci.org/tobinus/python-podgen.svg?branch=master - :target: https://travis-ci.com/tobinus/python-podgen.svg?branch=master - :alt: Continuous Integration (Travis CI) +.. image:: https://github.com/tobinus/python-podgen/actions/workflows/run-tests.yaml/badge.svg + :target: https://github.com/tobinus/python-podgen/actions/workflows/run-tests.yaml + :alt: Continuous Integration (GitHub Actions) .. image:: https://readthedocs.org/projects/podgen/badge/?version=latest :target: http://podgen.readthedocs.io/en/latest/?badge=latest diff --git a/readme.md b/readme.md index b41d0a3..d1a8747 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,8 @@ PodGen (forked from python-feedgen) =================================== -![Build Status](https://github.com/tobinus/python-podgen/actions/workflows/ci.yaml/badge.svg)[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) +[![Build Status](https://github.com/tobinus/python-podgen/actions/workflows/run-tests.yaml/badge.svg)](https://github.com/tobinus/python-podgen/actions/workflows/run-tests.yaml) +[![Documentation Status](https://readthedocs.org/projects/podgen/badge/?version=latest)](http://podgen.readthedocs.io/en/latest/?badge=latest) This module can be used to generate podcast feeds in RSS format, and is From f2bf52425208e9c20b6b6c3d3a73a90d8522eb44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorben=20Werner=20Sj=C3=B8str=C3=B8m=20Dahl?= Date: Sun, 1 Jan 2023 15:48:56 +0100 Subject: [PATCH 200/200] Drop back to Ubuntu 18.04 --- .github/workflows/run-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 8e2b67c..7d7c0cf 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -6,7 +6,7 @@ on: jobs: setup-test: name: Install and test in Python ${{ matrix.python-version }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-18.04 strategy: matrix: python-version: ["2.7", "3.4", "3.5", "3.6", "3.7"]