diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 0000000..7d7c0cf --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,33 @@ +name: Run tests +on: + push: {} + workflow_call: {} + +jobs: + setup-test: + name: Install and test in Python ${{ matrix.python-version }} + runs-on: ubuntu-18.04 + strategy: + matrix: + python-version: ["2.7", "3.4", "3.5", "3.6", "3.7"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - 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 + 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/.gitignore b/.gitignore index b6f6775..2859dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,18 @@ venv *.pyo *.swp -feedgen/tests/tmp_Atomfeed.xml +# JetBrains IDE +.idea/ -feedgen/tests/tmp_Rssfeed.xml +# Visual Studio Code +.vscode/ -tmp_Atomfeed.xml +# Documentation build +/doc/_build +/docs -tmp_Rssfeed.xml +# Distribution and building the package +/dist +/MANIFEST +/build +/podgen.egg-info diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 219a9a2..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: python - -python: - - "2.6" - - "2.7" - - "3.2" - - "3.3" - - "3.4" - -before_install: pip install --quiet lxml python-dateutil - -script: make test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..35db318 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# 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 + +- 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 + contains a full episode, a trailer or bonus material. +- Support for `Episode.season` for indicating what season the episode belongs + to. + + +## [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]. +- 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 + +- This `CHANGELOG.md` file, for documenting notable changes. +- Documentation page about PodGen's roadmap (under Background). + +### 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. + +### 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. + +[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/Makefile b/Makefile index b61b9b6..fe5ab5d 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,21 @@ -sdist: doc +# Modified work Copyright 2020, Thorben Dahl +# See license.* for more details + +sdist: python setup.py sdist +wheel: + python setup.py bdist_wheel --universal + 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 - @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml -doc: doc-clean doc-html doc-man doc-latexpdf +doc: doc-clean doc-html doc-clean: @echo Removing docs... @@ -22,11 +27,9 @@ 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 -r doc/_build/html/ext/ docs/html/ + @cp -T -r doc/_build/html/ docs/html/ +# Not supported doc-man: @echo 'Generating manpage' @make -C doc man @@ -34,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 @@ -41,11 +45,11 @@ doc-latexpdf: @echo 'Copying pdf to into docs dir' @cp doc/_build/latex/*.pdf docs/pdf/ -publish: sdist - python setup.py register sdist upload +publish: sdist wheel + twine upload dist/* test: - python -m unittest feedgen.tests.test_feed - python -m unittest feedgen.tests.test_entry - python -m unittest feedgen.tests.test_extension - @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml + @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 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/_static/custom.css b/doc/_static/custom.css new file mode 100644 index 0000000..a87316e --- /dev/null +++ b/doc/_static/custom.css @@ -0,0 +1,61 @@ +body { + background-color: #fffded; +} +div.documentwrapper, div.body { + background: initial; +} +pre { + background-color: #ebebeb; +} +div.sphinxsidebar a:link.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 { + display: block; + filter: invert(100%); + 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); + } +} diff --git a/doc/_static/favicon.ico b/doc/_static/favicon.ico new file mode 100644 index 0000000..3a795ba Binary files /dev/null and b/doc/_static/favicon.ico differ diff --git a/doc/_static/favicon.xcf b/doc/_static/favicon.xcf new file mode 100644 index 0000000..22cf4da Binary files /dev/null and b/doc/_static/favicon.xcf differ 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/logo.png b/doc/_static/logo.png new file mode 100644 index 0000000..8270619 Binary files /dev/null and b/doc/_static/logo.png differ 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/advanced/extending.rst b/doc/advanced/extending.rst new file mode 100644 index 0000000..bceba18 --- /dev/null +++ b/doc/advanced/extending.rst @@ -0,0 +1,218 @@ +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. + +.. 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 element out of the box. + +.. _GitHub Issues: https://github.com/tobinus/python-podgen/issues + + +Quick How-to +------------ + +#. Create new class that extends :class:`.Podcast`. +#. Add the new attribute. +#. 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 :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`_ 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 + +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 + + + 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 + + # 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: + # self._nsmap['prefix'] = "URI" + + @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 + """ + # 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) + 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 +^^^^^^^^^^^^ + +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. + +:: + + 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`` 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 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 :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 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 +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 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 elements you need. 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..40a73f0 --- /dev/null +++ b/doc/advanced/pubsubhubbub.rst @@ -0,0 +1,141 @@ +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. + +.. 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:: + :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 open source alternatives, like for instance `Switchboard`_. + +.. _Wikipedia article: https://en.wikipedia.org/wiki/PubSubHubbub#Usage +.. _Switchboard: https://github.com/aaronpk/Switchboard + +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 +.. _Flask: http://flask.pocoo.org/ + +Step 5: Notify the hub of new episodes +-------------------------------------- + +.. warning:: + + 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.** + +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. + +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:: + + from pubsubhubbub_publish import publish, PublishError + from podgen import Podcast + # ... + try: + publish(p.pubsubhubbub, p.feed_url) + except PublishError as e: + # Handle error + +In all other cases, you're encouraged to use `Requests`_ to make the necessary +`POST request`_ (if no publisher package is available). + +.. note:: + + 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/doc/api.category.rst b/doc/api.category.rst new file mode 100644 index 0000000..662bfe7 --- /dev/null +++ b/doc/api.category.rst @@ -0,0 +1,5 @@ +podgen.Category +=============== + +.. autoclass:: podgen.Category + :members: diff --git a/doc/api.entry.rst b/doc/api.entry.rst deleted file mode 100644 index eab7d2f..0000000 --- a/doc/api.entry.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. raw:: html - -
Contents
-
- -.. automodule:: feedgen.entry - :members: diff --git a/doc/api.episode.rst b/doc/api.episode.rst new file mode 100644 index 0000000..d1719e5 --- /dev/null +++ b/doc/api.episode.rst @@ -0,0 +1,6 @@ +============== +podgen.Episode +============== + +.. autoclass:: podgen.Episode + :members: diff --git a/doc/api.feed.rst b/doc/api.feed.rst deleted file mode 100644 index bc165c0..0000000 --- a/doc/api.feed.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. raw:: html - -
Contents
-
- -.. automodule:: feedgen.feed - :members: diff --git a/doc/api.media.rst b/doc/api.media.rst new file mode 100644 index 0000000..6b6bca6 --- /dev/null +++ b/doc/api.media.rst @@ -0,0 +1,5 @@ +podgen.Media +============ + +.. autoclass:: podgen.Media + :members: diff --git a/doc/api.person.rst b/doc/api.person.rst new file mode 100644 index 0000000..e7363df --- /dev/null +++ b/doc/api.person.rst @@ -0,0 +1,6 @@ +============= +podgen.Person +============= + +.. autoclass:: podgen.Person + :members: diff --git a/doc/api.podcast.rst b/doc/api.podcast.rst new file mode 100644 index 0000000..f251564 --- /dev/null +++ b/doc/api.podcast.rst @@ -0,0 +1,6 @@ +============== +podgen.Podcast +============== + +.. autoclass:: podgen.Podcast + :members: diff --git a/doc/api.rst b/doc/api.rst index 6aca66e..f2cca87 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -2,18 +2,24 @@ API Documentation ================= -.. automodule:: feedgen - :members: +.. autosummary:: -Contents: + podgen.Podcast + podgen.Episode + podgen.Person + podgen.Media + podgen.Category + podgen.warnings + podgen.util .. toctree:: :maxdepth: 2 + :hidden: - api.feed - api.entry + api.podcast + api.episode + api.person + api.media + api.category + api.warnings api.util - ext/api.ext.base - ext/api.ext.dc - ext/api.ext.podcast - ext/api.ext.podcast_entry diff --git a/doc/api.util.rst b/doc/api.util.rst index 47747d5..ac8dd62 100644 --- a/doc/api.util.rst +++ b/doc/api.util.rst @@ -1,7 +1,2 @@ -.. raw:: html - -
Contents
-
- -.. automodule:: feedgen.util +.. automodule:: podgen.util :members: 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/background/fork.rst b/doc/background/fork.rst new file mode 100644 index 0000000..a9cdf03 --- /dev/null +++ b/doc/background/fork.rst @@ -0,0 +1,97 @@ +============= +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. + +A more detailed reasoning follows. Read it if you're interested, but feel free +to skip to the :doc:`/usage_guide/index`. + +Inspiration +----------- + +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 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 +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. This is the inspiration for forking python-feedgen_ and +rewrite the API, without mixing the different standards. + +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 +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. + +Forking a project gives you a lot of freedom, since you don't have to support +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 +------------------ + +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. + +The following list is not exhaustive. + +* 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 + * 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. +* :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. +* 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..304d43b --- /dev/null +++ b/doc/background/index.rst @@ -0,0 +1,14 @@ +========== +Background +========== + +Learn about the "why" and "how" of the PodGen project itself. + +.. toctree:: + :maxdepth: 1 + + philosophy + scope + fork + roadmap + 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/roadmap.rst b/doc/background/roadmap.rst new file mode 100644 index 0000000..fa2a935 --- /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, + 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 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/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 0795320..3e8bdc9 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 @@ -11,20 +23,25 @@ sys.path.insert(0, os.path.abspath('../')) sys.path.insert(0, os.path.abspath('.')) -import feedgen.version +import podgen.version # -- 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'] # 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. extensions = [ - 'sphinx.ext.intersphinx', - 'sphinx.ext.coverage', - 'sphinx.ext.autodoc' - ] + 'sphinx.ext.intersphinx', + 'sphinx.ext.coverage', + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.viewcode', + ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -39,17 +56,17 @@ master_doc = 'index' # General information about the project. -project = u'python-feedgen' -copyright = u'2013, Lars Kiesow' +project = u'PodGen' +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 # 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. @@ -90,25 +107,47 @@ # 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': False, + 'page_width': "1000px", + 'sidebar_width': "225px", + '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(198, 198, 198, 0.9)", + + 'description': 'Generate podcasts with ease.', + 'show_relbars': True, + + 'github_user': 'tobinus', + 'github_repo': 'python-podgen', + '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. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +html_title = "PodGen Documentation" # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +html_short_title = "PodGen" # The name of an image file (relative to this directory) to place at the top # of the sidebar. @@ -117,7 +156,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 = None +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, @@ -126,14 +165,27 @@ # 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 = { + 'index': [ + 'about.html', + 'navigation.html', + 'searchbox.html', + 'donate.html', + ], + '**': [ + 'about.html', + 'navigation.html', + 'searchbox.html', + 'donate.html', + ] +} # Additional templates that should be rendered to pages, maps page names to # template names. @@ -166,7 +218,7 @@ #html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'pyFeedGen' +htmlhelp_basename = 'pyPodGen' # -- Options for LaTeX output -------------------------------------------------- @@ -185,13 +237,13 @@ # 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', '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. @@ -201,13 +253,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 -------------------------------------------- @@ -215,12 +267,12 @@ # 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', 'pyPodGen.tex', u'PodGen Documentation', + [u'Lars Kiesow', u'Thorben Dahl'], 1) ] # If true, show URL addresses after external links. -#man_show_urls = False +man_show_urls = True # -- Options for Texinfo output ------------------------------------------------ @@ -229,9 +281,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', 'pyPodGen.tex', u'PodGen Documentation', + u'Lars Kiesow, Thorben Dahl', 'Lernfunk3', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. @@ -245,39 +297,40 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None), + 'requests': ('https://requests.readthedocs.io/en/master', None)} # Ugly way of setting tabsize import re def process_docstring(app, what, name, obj, options, lines): - ''' - spaces_pat = re.compile(r"(?<= )( {8})") - ll = [] - for l in lines: - ll.append(spaces_pat.sub(" ",l)) - ''' - spaces_pat = re.compile(r"^ *") - ll = [] - for l in lines: - spacelen = len(spaces_pat.search(l).group(0)) - newlen = (spacelen % 8) + (spacelen / 8 * 4) - ll.append( (' '*newlen) + l.lstrip(' ') ) - lines[:] = ll + ''' + 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 = int((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/doc/contributing.rst b/doc/contributing.rst new file mode 100644 index 0000000..496be75 --- /dev/null +++ b/doc/contributing.rst @@ -0,0 +1,92 @@ +============ +Contributing +============ + +Setting up +---------- + +To install the dependencies, run:: + + $ pip install -r requirements.txt + +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:`/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 +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 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 +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. + * 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 + 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/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/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/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/doc/index.rst b/doc/index.rst index 22fc46b..958a167 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,25 +1,84 @@ -.. contents:: Table of Contents +====== +PodGen +====== -.. include-github-readme +.. 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) -.. raw:: html +.. image:: https://readthedocs.org/projects/podgen/badge/?version=latest + :target: http://podgen.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status -
+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:: -==================== -Module documentation -==================== + 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 +best practices and lets you focus on collecting the necessary metadata and +publishing the podcast. + +PodGen is compatible with Python 2.7 and 3.4+. + +.. warning:: + + 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. + Please refer to the :doc:`background/roadmap`. + + +Contents +-------- .. toctree:: - :maxdepth: 2 + :maxdepth: 3 + background/index + usage_guide/index + advanced/index + contributing api -================== -Indices and tables -================== -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +External Resources +------------------ +* `Changelog `_ +* `GitHub Repository `_ +* `Python Package Index `_ diff --git a/doc/usage_guide/episodes.rst b/doc/usage_guide/episodes.rst new file mode 100644 index 0000000..2cfd307 --- /dev/null +++ b/doc/usage_guide/episodes.rst @@ -0,0 +1,383 @@ + +Episodes +-------- + +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) + 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. + +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" + +Read more: + +* :attr:`~podgen.Episode.title` +* :attr:`~podgen.Episode.subtitle` +* :attr:`~podgen.Episode.summary` +* :attr:`~podgen.Episode.long_summary` + +.. _podgen.Media-guide: + +Enclosing media +^^^^^^^^^^^^^^^ + +Of course, this isn't much of a podcast if we don't have any +:attr:`~podgen.Episode.media` attached to it! :: + + from datetime import timedelta + from podgen import Media + 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) + ) + +The media's attributes (and the arguments to the constructor) are: + +======================== ======================================================= +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: + +======================== ======================================================= +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`. +======================== ======================================================= + +.. 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 +==================================== + +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:: + + my_episode.media = Media.create_from_server_response( + "http://example.com/podcast/s01e10.mp3", + duration=timedelta(hours=1, minutes=2, seconds=36) + ) + +Here's the effect of leaving out the fields: + +======================== ======================================================= +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 +=============================== + +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() + +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:: + + 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: + +* :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 +^^^^^^^^^^^^^^^^^^^^^^^ + +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``. + +.. warning:: + + 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 `. + +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 +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +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. + +.. note:: + + It is generally a bad idea to use the media file's modification date + 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 `. + + +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:`~podgen.Episode.summary` by following +the link. :: + + my_episode.link = "http://example.com/article/2016/05/18/Best-example" + +.. note:: + + 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 `. + + +The Authors +^^^^^^^^^^^ + +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 list of authors differs from the podcast's, though, you can +override it like this:: + + my_episode.authors = [Person("Joe Bob")] + +You can even have multiple authors:: + + my_episode.authors = [Person("Joe Bob"), Person("Alice Bob")] + +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 +^^^^^^^^^^^^^^^^^^^^ + +:: + + # 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 + + # 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 + + # Careful! This will hide this episode from the iTunes store page. + my_episode.withhold_from_itunes = True + +More details: + +* :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 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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 +attribute name as the keyword:: + + Episode( + =, + =, + ... + ) + +See also the example in :doc:`the API Documentation `. diff --git a/doc/usage_guide/example.rst b/doc/usage_guide/example.rst new file mode 100644 index 0000000..37ee96b --- /dev/null +++ b/doc/usage_guide/example.rst @@ -0,0 +1,11 @@ +============ +Full example +============ + +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: + diff --git a/doc/usage_guide/index.rst b/doc/usage_guide/index.rst new file mode 100644 index 0000000..f63d89a --- /dev/null +++ b/doc/usage_guide/index.rst @@ -0,0 +1,14 @@ +=========== +Usage Guide +=========== + +This part of the manual provides a guided tour of the PodGen library. + +.. 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/usage_guide/podcasts.rst b/doc/usage_guide/podcasts.rst new file mode 100644 index 0000000..a4c4ace --- /dev/null +++ b/doc/usage_guide/podcasts.rst @@ -0,0 +1,201 @@ +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() + +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 = False + +They're mostly 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 +~~~~~ + +A podcast's image is worth special attention:: + + p.image = "https://example.com/static/example_podcast.png" + +.. autoattribute:: podgen.Podcast.image + :noindex: + +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 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 +~~~~~~~~~~~~~~~~~~~ + +There are plenty of other attributes that can be used with +:class:`podgen.Podcast `: + + +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" # URL of this feed + p.category = Category("Music", "Music History") + p.owner = p.authors[0] + p.xslt = "https://example.com/feed/stylesheet.xsl" # URL of XSLT stylesheet + +Read more: + +* :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` +* :attr:`~podgen.Podcast.xslt` + + +Less commonly used +^^^^^^^^^^^^^^^^^^ + +Some of those are obscure while some of them are often times not needed. Others +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)) + + # 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") + + # 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: + +* :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` +* :meth:`~podgen.Podcast.set_generator` +* :attr:`~podgen.Podcast.new_feed_url` +* :attr:`~podgen.Podcast.complete` +* :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 +new :class:`.Podcast` object and fill it with data in one statement. Simply +use the attribute name as keyword arguments to the constructor:: + + import podgen + p = podgen.Podcast( + =, + =, + ... + ) + +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/usage_guide/rss.rst b/doc/usage_guide/rss.rst new file mode 100644 index 0000000..2edc2cd --- /dev/null +++ b/doc/usage_guide/rss.rst @@ -0,0 +1,41 @@ + +RSS +--- + +Once you've added all the information and episodes, you're ready 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:`podgen.Podcast.rss_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) + +.. autosummary:: + + ~podgen.Podcast.rss_str + +You may also write the feed to a file directly, using :meth:`podgen.Podcast.rss_file`:: + + p.rss_file('rss.xml', minimize=True) + + +.. autosummary:: + + ~podgen.Podcast.rss_file + +.. 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/feedgen/__init__.py b/feedgen/__init__.py deleted file mode 100644 index aef2c72..0000000 --- a/feedgen/__init__.py +++ /dev/null @@ -1,136 +0,0 @@ -# -*- coding: utf-8 -*- -""" - ======= - 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. - - :copyright: 2013 by Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. - - - ------------- - Create a Feed - ------------- - - To create a feed simply instanciate the FeedGenerator class and insert some - data:: - - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() - >>> fg.id('http://lernfunk.de/media/654321') - >>> fg.title('Some Testfeed') - >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) - >>> fg.link( href='http://example.com', rel='alternate' ) - >>> fg.logo('http://ex.com/logo.jpg') - >>> fg.subtitle('This is a cool feed!') - >>> fg.link( href='http://larskiesow.de/test.atom', rel='self' ) - >>> fg.language('en') - - 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 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 - - - ---------------- - 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:: - - >>> 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. - - ---------- - 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) - - 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. - - 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** - - 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:: - - >>> 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`. - - --------------------- - Testing the Generator - --------------------- - - You can test the module by simply executing:: - - $ python -m feedgen - -""" diff --git a/feedgen/__main__.py b/feedgen/__main__.py deleted file mode 100644 index 4463ea6..0000000 --- a/feedgen/__main__.py +++ /dev/null @@ -1,116 +0,0 @@ -# -*- coding: utf-8 -*- -''' - feedgen - ~~~~~~~ - - :copyright: 2013, Lars Kiesow - - :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) - - - -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() - - arg = sys.argv[1] - - fg = FeedGenerator() - fg.id('http://lernfunk.de/_MEDIAID_123') - fg.title('Testfeed') - fg.author( {'name':'Lars Kiesow','email':'lkiesow@uos.de'} ) - fg.link( href='http://example.com', rel='alternate' ) - fg.category(term='test') - fg.contributor( name='Lars Kiesow', email='lkiesow@uos.de' ) - fg.contributor( name='John Doe', email='jdoe@example.com' ) - fg.icon('http://ex.com/icon.jpg') - fg.logo('http://ex.com/logo.jpg') - fg.rights('cc-by') - fg.subtitle('This is a cool feed!') - fg.link( href='http://larskiesow.de/test.atom', rel='self' ) - fg.language('de') - fe = fg.add_entry() - fe.id('http://lernfunk.de/_MEDIAID_123#1') - fe.title('First Element') - fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen - aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si ista - mala sunt, placet. Aut etiam, ut vestitum, sic sententiam habeas aliam - domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas - occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, - verba.''') - fe.summary(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…') - fe.link( href='http://example.com', rel='alternate' ) - fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) - - if arg == 'atom': - print_enc (fg.atom_str(pretty=True)) - elif arg == 'rss': - print_enc (fg.rss_str(pretty=True)) - elif arg == 'podcast': - # Load the podcast extension. It will automatically be loaded for all - # entries in the feed, too. Thus also for our “fe”. - fg.load_extension('podcast') - fg.podcast.itunes_author('Lars Kiesow') - fg.podcast.itunes_category('Technology', 'Podcasting') - fg.podcast.itunes_explicit('no') - fg.podcast.itunes_complete('no') - fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') - fg.podcast.itunes_owner('John Doe', 'john@example.com') - fg.podcast.itunes_summary('Lorem ipsum dolor sit amet, ' + \ - 'consectetur adipiscing elit. ' + \ - 'Verba tu fingas et ea dicas, quae non sentias?') - fe.podcast.itunes_author('Lars Kiesow') - print_enc (fg.rss_str(pretty=True)) - - elif arg.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 deleted file mode 100644 index bf50357..0000000 --- a/feedgen/entry.py +++ /dev/null @@ -1,656 +0,0 @@ -# -*- coding: utf-8 -*- -''' - feedgen.entry - ~~~~~~~~~~~~~ - - :copyright: 2013, Lars Kiesow - - :license: FreeBSD and LGPL, see license.* for more details. -''' - -from lxml import etree -from datetime import datetime -import dateutil.parser -import dateutil.tz -from feedgen.util import ensure_format, formatRFC2822 -from feedgen.compat import string_types - - -class FeedEntry(object): - '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item - node. - ''' - - def __init__(self): - # ATOM - # required - self.__atom_id = None - self.__atom_title = None - self.__atom_updated = datetime.now(dateutil.tz.tzutc()) - - # recommended - self.__atom_author = None - self.__atom_content = None - self.__atom_link = None - self.__atom_summary = None - - # optional - self.__atom_category = None - self.__atom_contributor = None - self.__atom_published = None - self.__atom_source = None - self.__atom_rights = None - - # RSS - self.__rss_author = None - self.__rss_category = None - self.__rss_comments = None - self.__rss_description = None - self.__rss_content = None - self.__rss_enclosure = None - self.__rss_guid = None - self.__rss_link = None - self.__rss_pubDate = None - self.__rss_source = None - self.__rss_title = None - - # Extension list: - self.__extensions = {} - - - 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 deleted file mode 100644 index a3dd0f9..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 da7f571..0000000 --- a/feedgen/ext/base.py +++ /dev/null @@ -1,43 +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 - - - 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/ext/dc.py b/feedgen/ext/dc.py deleted file mode 100644 index 69835d2..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/podcast.py b/feedgen/ext/podcast.py deleted file mode 100644 index 616473e..0000000 --- a/feedgen/ext/podcast.py +++ /dev/null @@ -1,313 +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. - :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. - :param itunes_subcategory: Subcategory of the podcast. - :returns: Category data of the podcast. - ''' - if not itunes_category is None: - if not itunes_category in self._itunes_categories.keys(): - raise ValueError('Invalid category') - cat = {'cat':itunes_category} - if not itunes_subcategory is None: - if not itunes_subcategory in self._itunes_categories[itunes_category]: - raise ValueError('Invalid subcategory') - cat['sub'] = itunes_subcategory - self.__itunes_category = cat - return self.__itunes_category - - - def itunes_image(self, itunes_image=None): - '''Get or set the image for the podcast. This tag specifies the artwork - for your podcast. Put the URL to the image in the href attribute. iTunes - prefers square .jpg images that are at least 1400x1400 pixels, which is - different from what is specified for the standard RSS image tag. In order - for a podcast to be eligible for an iTunes Store feature, the - accompanying image must be at least 1400x1400 pixels. - - iTunes supports images in JPEG and PNG formats with an RGB color space - (CMYK is not supported). The URL must end in ".jpg" or ".png". If the - tag is not present, iTunes will use the contents of the - RSS image tag. - - If you change your podcast’s image, also change the file’s name. iTunes - may not change the image if it checks your feed and the image URL is the - same. The server hosting your cover art image must allow HTTP head - requests for iTS to be able to automatically update your cover art. - - :param itunes_image: Image of the podcast. - :returns: Image of the podcast. - ''' - if not itunes_image is None: - if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ): - ValueError('Image file must be png or jpg') - self.__itunes_image = itunes_image - return self.__itunes_image - - - def itunes_explicit(self, itunes_explicit=None): - '''Get or the the itunes:explicit value of the podcast. This tag should - be used to indicate whether your podcast contains explicit material. The - three values for this tag are "yes", "no", and "clean". - - If you populate this tag with "yes", an "explicit" parental advisory - graphic will appear next to your podcast artwork on the iTunes Store and - in the Name column in iTunes. If the value is "clean", the parental - advisory type is considered Clean, meaning that no explicit language or - adult content is included anywhere in the episodes, and a "clean" graphic - will appear. If the explicit tag is present and has any other value - (e.g., "no"), you see no indicator — blank is the default advisory type. - - :param itunes_explicit: If the podcast contains explicit material. - :returns: If the podcast contains explicit material. - ''' - if not itunes_explicit is None: - if not itunes_explicit in ('', 'yes', 'no', 'clean'): - raise ValueError('Invalid value for explicit tag') - self.__itunes_explicit = itunes_explicit - return self.__itunes_explicit - - - def itunes_complete(self, itunes_complete=None): - '''Get or set the itunes:complete value of the podcast. This tag can be - used to indicate the completion of a podcast. - - If you populate this tag with "yes", you are indicating that no more - episodes will be added to the podcast. If the tag is - present and has any other value (e.g. “no”), it will have no effect on - the podcast. - - :param itunes_complete: If the podcast is complete. - :returns: If the podcast is complete. - ''' - if not itunes_complete is None: - if not itunes_complete in ('yes', 'no', '', True, False): - raise ValueError('Invalid value for complete tag') - if itunes_complete == True: - itunes_complete = 'yes' - if itunes_complete == False: - itunes_complete = 'no' - self.__itunes_complete = itunes_complete - return self.__itunes_complete - - - def itunes_new_feed_url(self, itunes_new_feed_url=None): - '''Get or set the new-feed-url property of the podcast. This tag allows - you to change the URL where the podcast feed is located - - After adding the tag to your old feed, you should maintain the old feed - for 48 hours before retiring it. At that point, iTunes will have updated - the directory with the new feed URL. - - :param itunes_new_feed_url: New feed URL. - :returns: New feed URL. - ''' - if not itunes_new_feed_url is None: - self.__itunes_new_feed_url = itunes_new_feed_url - return self.__itunes_new_feed_url - - - def itunes_owner(self, name=None, email=None): - '''Get or set the itunes:owner of the podcast. This tag contains - information that will be used to contact the owner of the podcast for - communication specifically about the podcast. It will not be publicly - displayed. - - :param itunes_owner: The owner of the feed. - :returns: Data of the owner of the feed. - ''' - if not name is None: - if name and email: - self.__itunes_owner = {'name':name, 'email':email} - elif not name and not email: - self.__itunes_owner = None - else: - raise ValueError('Both name and email have to be set.') - return self.__itunes_owner - - - def itunes_subtitle(self, itunes_subtitle=None): - '''Get or set the itunes:subtitle value for the podcast. The contents of - this tag are shown in the Description column in iTunes. The subtitle - displays best if it is only a few words long. - - :param itunes_subtitle: Subtitle of the podcast. - :returns: Subtitle of the podcast. - ''' - if not itunes_subtitle is None: - self.__itunes_subtitle = itunes_subtitle - return self.__itunes_subtitle - - - def itunes_summary(self, itunes_summary=None): - '''Get or set the itunes:summary value for the podcast. The contents of - this tag are shown in a separate window that appears when the "circled i" - in the Description column is clicked. It also appears on the iTunes page - for your podcast. This field can be up to 4000 characters. If - is not included, the contents of the tag - are used. - - :param itunes_summary: Summary of the podcast. - :returns: Summary of the podcast. - ''' - if not itunes_summary is None: - self.__itunes_summary = itunes_summary - return self.__itunes_summary - - - _itunes_categories = { - 'Arts': [ 'Design', 'Fashion & Beauty', 'Food', 'Literature', - 'Performing Arts', 'Visual Arts' ], - 'Business' : [ 'Business News', 'Careers', 'Investing', - 'Management & Marketing', 'Shopping' ], - 'Comedy' : [], - 'Education' : [ 'Education', 'Education Technology', - 'Higher Education', 'K-12', 'Language Courses', 'Training' ], - 'Games & Hobbies' : [ 'Automotive', 'Aviation', 'Hobbies', - 'Other Games', 'Video Games' ], - 'Government & Organizations' : [ 'Local', 'National', 'Non-Profit', - 'Regional' ], - 'Health' : [ 'Alternative Health', 'Fitness & Nutrition', 'Self-Help', - 'Sexuality' ], - 'Kids & Family' : [], - 'Music' : [], - 'News & Politics' : [], - 'Religion & Spirituality' : [ 'Buddhism', 'Christianity', 'Hinduism', - 'Islam', 'Judaism', 'Other', 'Spirituality' ], - 'Science & Medicine' : [ 'Medicine', 'Natural Sciences', - 'Social Sciences' ], - 'Society & Culture' : [ 'History', 'Personal Journals', 'Philosophy', - 'Places & Travel' ], - 'Sports & Recreation' : [ 'Amateur', 'College & High School', - 'Outdoor', 'Professional' ], - 'Technology' : [ 'Gadgets', 'Tech News', 'Podcasting', - 'Software How-To' ], - 'TV & Film' : [] - } diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py deleted file mode 100644 index 179694c..0000000 --- a/feedgen/ext/podcast_entry.py +++ /dev/null @@ -1,243 +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. - :returns: The author of the podcast. - ''' - if not itunes_author is None: - self.__itunes_author = itunes_author - return self.__itunes_author - - - def itunes_block(self, itunes_block=None): - '''Get or set the ITunes block attribute. Use this to prevent episodes - from appearing in the iTunes podcast directory. - - :param itunes_block: Block podcast episodes. - :returns: If the podcast episode is blocked. - ''' - if not itunes_block is None: - self.__itunes_block = itunes_block - return self.__itunes_block - - - def itunes_image(self, itunes_image=None): - '''Get or set the image for the podcast episode. This tag specifies the - artwork for your podcast. Put the URL to the image in the href attribute. - iTunes prefers square .jpg images that are at least 1400x1400 pixels, - which is different from what is specified for the standard RSS image tag. - In order for a podcast to be eligible for an iTunes Store feature, the - accompanying image must be at least 1400x1400 pixels. - - iTunes supports images in JPEG and PNG formats with an RGB color space - (CMYK is not supported). The URL must end in ".jpg" or ".png". If the - tag is not present, iTunes will use the contents of the - RSS image tag. - - If you change your podcast’s image, also change the file’s name. iTunes - may not change the image if it checks your feed and the image URL is the - same. The server hosting your cover art image must allow HTTP head - requests for iTS to be able to automatically update your cover art. - - :param itunes_image: Image of the podcast. - :returns: Image of the podcast. - ''' - if not itunes_image is None: - if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ): - ValueError('Image file must be png or jpg') - self.__itunes_image = itunes_image - return self.__itunes_image - - - def itunes_duration(self, itunes_duration=None): - '''Get or set the duration of the podcast episode. The content of this - tag is shown in the Time column in iTunes. - - The tag can be formatted HH:MM:SS, H:MM:SS, MM:SS, or M:SS (H = hours, - M = minutes, S = seconds). If an integer is provided (no colon present), - the value is assumed to be in seconds. If one colon is present, the - number to the left is assumed to be minutes, and the number to the right - is assumed to be seconds. If more than two colons are present, the - numbers farthest to the right are ignored. - - :param itunes_duration: Duration of the podcast episode. - :returns: Duration of the podcast episode. - ''' - if not itunes_duration is None: - itunes_duration = str(itunes_duration) - if len(itunes_duration.split(':')) > 3 or \ - itunes_duration.lstrip('0123456789:') != '': - ValueError('Invalid duration format') - self.__itunes_duration = itunes_duration - return self.itunes_duration - - - def itunes_explicit(self, itunes_explicit=None): - '''Get or the the itunes:explicit value of the podcast episode. This tag - should be used to indicate whether your podcast episode contains explicit - material. The three values for this tag are "yes", "no", and "clean". - - If you populate this tag with "yes", an "explicit" parental advisory - graphic will appear next to your podcast artwork on the iTunes Store and - in the Name column in iTunes. If the value is "clean", the parental - advisory type is considered Clean, meaning that no explicit language or - adult content is included anywhere in the episodes, and a "clean" graphic - will appear. If the explicit tag is present and has any other value - (e.g., "no"), you see no indicator — blank is the default advisory type. - - :param itunes_explicit: If the podcast episode contains explicit material. - :returns: If the podcast episode contains explicit material. - ''' - if not itunes_explicit is None: - if not itunes_explicit in ('', 'yes', 'no', 'clean'): - raise ValueError('Invalid value for explicit tag') - self.__itunes_explicit = itunes_explicit - return self.__itunes_explicit - - - def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None): - '''Get or set the is_closed_captioned value of the podcast episode. This - tag should be used if your podcast includes a video episode with embedded - closed captioning support. The two values for this tag are "yes" and - "no”. - - :param is_closed_captioned: If the episode has closed captioning support. - :returns: If the episode has closed captioning support. - ''' - if not itunes_is_closed_captioned is None: - self.__itunes_is_closed_captioned = itunes_is_closed_captioned in ('yes', True) - return self.__itunes_is_closed_captioned - - - def itunes_order(self, itunes_order=None): - '''Get or set the itunes:order value of the podcast episode. This tag can - be used to override the default ordering of episodes on the store. - - This tag is used at an level by populating with the number value - in which you would like the episode to appear on the store. For example, - if you would like an to appear as the first episode in the - podcast, you would populate the tag with “1”. If - conflicting order values are present in multiple episodes, the store will - use default ordering (pubDate). - - To remove the order from the episode set the order to a value below zero. - - :param itunes_order: The order of the episode. - :returns: The order of the episode. - ''' - if not itunes_order is None: - self.__itunes_order = int(itunes_order) - return self.__itunes_order - - - def itunes_subtitle(self, itunes_subtitle=None): - '''Get or set the itunes:subtitle value for the podcast episode. The - contents of this tag are shown in the Description column in iTunes. The - subtitle displays best if it is only a few words long. - - :param itunes_subtitle: Subtitle of the podcast episode. - :returns: Subtitle of the podcast episode. - ''' - if not itunes_subtitle is None: - self.__itunes_subtitle = itunes_subtitle - return self.__itunes_subtitle - - - def itunes_summary(self, itunes_summary=None): - '''Get or set the itunes:summary value for the podcast episode. The - contents of this tag are shown in a separate window that appears when the - "circled i" in the Description column is clicked. It also appears on the - iTunes page for your podcast. This field can be up to 4000 characters. If - is not included, the contents of the tag - are used. - - :param itunes_summary: Summary of the podcast episode. - :returns: Summary of the podcast episode. - ''' - if not itunes_summary is None: - self.__itunes_summary = itunes_summary - return self.__itunes_summary 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/feed.py b/feedgen/feed.py deleted file mode 100644 index 1d88bc3..0000000 --- a/feedgen/feed.py +++ /dev/null @@ -1,1129 +0,0 @@ -# -*- coding: utf-8 -*- -''' - feedgen.feed - ~~~~~~~~~~~~ - - :copyright: 2013, Lars Kiesow - - :license: FreeBSD and LGPL, see license.* for more details. - -''' - -from lxml import etree -from datetime import datetime -import dateutil.parser -import dateutil.tz -from feedgen.entry import FeedEntry -from feedgen.util import ensure_format, formatRFC2822 -import feedgen.version -import sys -from feedgen.compat import string_types - - -_feedgen_version = feedgen.version.version_str - - -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. - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :param encoding: Encoding used in the XML file (default: UTF-8). - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :returns: String representation of the ATOM feed. - ''' - feed, doc = self._create_atom(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) - - - def atom_file(self, filename, extensions=True, pretty=False, - encoding='UTF-8', xml_declaration=True): - '''Generates an ATOM feed and write the resulting XML to a file. - - :param filename: Name of file to write, or a file-like object, or a URL. - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :param pretty: If the feed should be split into multiple lines and - properly indented. - :param encoding: Encoding used in the XML file (default: UTF-8). - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - ''' - feed, doc = self._create_atom(extensions=extensions) - doc.write(filename, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) - - - def _create_rss(self, extensions=True): - '''Create an RSS feed xml structure containing all previously set fields. - - :returns: Tuple containing the feed root element and the element tree. - ''' - nsmap = dict() - if extensions: - for ext in self.__extensions.values() or []: - if ext.get('rss'): - nsmap.update( ext['inst'].extend_ns() ) - - nsmap.update({'atom': 'http://www.w3.org/2005/Atom', - 'content': 'http://purl.org/rss/1.0/modules/content/'}) - - feed = etree.Element('rss', version='2.0', nsmap=nsmap ) - channel = etree.SubElement(feed, 'channel') - if not ( self.__rss_title and self.__rss_link and self.__rss_description ): - missing = ', '.join(([] if self.__rss_title else ['title']) + \ - ([] if self.__rss_link else ['link']) + \ - ([] if self.__rss_description else ['description'])) - raise ValueError('Required fields not set (%s)' % missing) - title = etree.SubElement(channel, 'title') - title.text = self.__rss_title - link = etree.SubElement(channel, 'link') - link.text = self.__rss_link - desc = etree.SubElement(channel, 'description') - desc.text = self.__rss_description - for ln in self.__atom_link or []: - # It is recommended to include a atom self link in rss documents… - if ln.get('rel') == 'self': - selflink = etree.SubElement(channel, - '{http://www.w3.org/2005/Atom}link', - href=ln['href'], rel='self') - if ln.get('type'): - selflink.attrib['type'] = ln['type'] - if ln.get('hreflang'): - selflink.attrib['hreflang'] = ln['hreflang'] - if ln.get('title'): - selflink.attrib['title'] = ln['title'] - if ln.get('length'): - selflink.attrib['length'] = ln['length'] - break - if self.__rss_category: - for cat in self.__rss_category: - category = etree.SubElement(channel, 'category') - category.text = cat['value'] - if cat.get('domain'): - category.attrib['domain'] = cat['domain'] - if self.__rss_cloud: - cloud = etree.SubElement(channel, 'cloud') - cloud.attrib['domain'] = self.__rss_cloud.get('domain') - cloud.attrib['port'] = self.__rss_cloud.get('port') - cloud.attrib['path'] = self.__rss_cloud.get('path') - cloud.attrib['registerProcedure'] = self.__rss_cloud.get( - 'registerProcedure') - cloud.attrib['protocol'] = self.__rss_cloud.get('protocol') - if self.__rss_copyright: - copyright = etree.SubElement(channel, 'copyright') - copyright.text = self.__rss_copyright - if self.__rss_docs: - docs = etree.SubElement(channel, 'docs') - docs.text = self.__rss_docs - if self.__rss_generator: - generator = etree.SubElement(channel, 'generator') - generator.text = self.__rss_generator - if self.__rss_image: - image = etree.SubElement(channel, 'image') - url = etree.SubElement(image, 'url') - url.text = self.__rss_image.get('url') - title = etree.SubElement(image, 'title') - title.text = self.__rss_image['title'] \ - if self.__rss_image.get('title') else self.__rss_title - link = etree.SubElement(image, 'link') - link.text = self.__rss_image['link'] \ - if self.__rss_image.get('link') else self.__rss_link - if self.__rss_image.get('width'): - width = etree.SubElement(image, 'width') - width.text = self.__rss_image.get('width') - if self.__rss_image.get('height'): - height = etree.SubElement(image, 'height') - height.text = self.__rss_image.get('height') - if self.__rss_image.get('description'): - description = etree.SubElement(image, 'description') - description.text = self.__rss_image.get('description') - if self.__rss_language: - language = etree.SubElement(channel, 'language') - language.text = self.__rss_language - if self.__rss_lastBuildDate: - lastBuildDate = etree.SubElement(channel, 'lastBuildDate') - - lastBuildDate.text = formatRFC2822(self.__rss_lastBuildDate) - if self.__rss_managingEditor: - managingEditor = etree.SubElement(channel, 'managingEditor') - managingEditor.text = self.__rss_managingEditor - if self.__rss_pubDate: - pubDate = etree.SubElement(channel, 'pubDate') - pubDate.text = formatRFC2822(self.__rss_pubDate) - if self.__rss_rating: - rating = etree.SubElement(channel, 'rating') - rating.text = self.__rss_rating - if self.__rss_skipHours: - skipHours = etree.SubElement(channel, 'skipHours') - for h in self.__rss_skipHours: - hour = etree.SubElement(skipHours, 'hour') - hour.text = str(h) - if self.__rss_skipDays: - skipDays = etree.SubElement(channel, 'skipDays') - for d in self.__rss_skipDays: - day = etree.SubElement(skipDays, 'day') - day.text = d - if self.__rss_textInput: - textInput = etree.SubElement(channel, 'textInput') - textInput.attrib['title'] = self.__rss_textInput.get('title') - textInput.attrib['description'] = self.__rss_textInput.get('description') - textInput.attrib['name'] = self.__rss_textInput.get('name') - textInput.attrib['link'] = self.__rss_textInput.get('link') - if self.__rss_ttl: - ttl = etree.SubElement(channel, 'ttl') - ttl.text = str(self.__rss_ttl) - if self.__rss_webMaster: - webMaster = etree.SubElement(channel, 'webMaster') - webMaster.text = self.__rss_webMaster - - if extensions: - for ext in self.__extensions.values() or []: - if ext.get('rss'): - ext['inst'].extend_rss(feed) - - for entry in self.__feed_entries: - item = entry.rss_entry() - channel.append(item) - - doc = etree.ElementTree(feed) - return feed, doc - - - def rss_str(self, pretty=False, extensions=True, encoding='UTF-8', - xml_declaration=True): - '''Generates an RSS feed and returns the feed XML as string. - - :param pretty: If the feed should be split into multiple lines and - properly indented. - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :param encoding: Encoding used in the XML file (default: UTF-8). - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :returns: String representation of the RSS feed. - ''' - feed, doc = self._create_rss(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) - - - def rss_file(self, filename, extensions=True, pretty=False, - encoding='UTF-8', xml_declaration=True): - '''Generates an RSS feed and write the resulting XML to a file. - - :param filename: Name of file to write, or a file-like object, or a URL. - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :param pretty: If the feed should be split into multiple lines and - properly indented. - :param encoding: Encoding used in the XML file (default: UTF-8). - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - ''' - feed, doc = self._create_rss(extensions=extensions) - doc.write(filename, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) - - - def title(self, title=None): - '''Get or set the title value of the feed. It should contain a human - readable title for the feed. Often the same as the title of the - associated website. Title is mandatory for both ATOM and RSS and should - not be blank. - - :param title: The new title of the feed. - :returns: The feeds title. - ''' - if not title is None: - self.__atom_title = title - self.__rss_title = title - return self.__atom_title - - - def id(self, id=None): - '''Get or set the feed id which identifies the feed using a universally - unique and permanent URI. If you have a long-term, renewable lease on - your Internet domain name, then you can feel free to use your website's - address. This field is for ATOM only. It is mandatory for ATOM. - - :param id: New Id of the ATOM feed. - :returns: Id of the feed. - ''' - - if not id is None: - self.__atom_id = id - return self.__atom_id - - - def updated(self, updated=None): - '''Set or get the updated value which indicates the last time the feed - was modified in a significant way. - - The value can either be a string which will automatically be parsed or a - datetime.datetime object. In any case it is necessary that the value - include timezone information. - - This will set both atom:updated and rss:lastBuildDate. - - Default value - If not set, updated has as value the current date and time. - - :param updated: The modification date. - :returns: Modification date as datetime.datetime - ''' - if not updated is None: - if isinstance(updated, string_types): - updated = dateutil.parser.parse(updated) - if not isinstance(updated, datetime): - raise ValueError('Invalid datetime format') - if updated.tzinfo is None: - raise ValueError('Datetime object has no timezone info') - self.__atom_updated = updated - self.__rss_lastBuildDate = updated - - return self.__atom_updated - - - def lastBuildDate(self, lastBuildDate=None): - '''Set or get the lastBuildDate value which indicates the last time the - content of the channel changed. - - The value can either be a string which will automatically be parsed or a - datetime.datetime object. In any case it is necessary that the value - include timezone information. - - This will set both atom:updated and rss:lastBuildDate. - - Default value - If not set, lastBuildDate has as value the current date and time. - - :param lastBuildDate: The modification date. - :returns: Modification date as datetime.datetime - ''' - return self.updated( lastBuildDate ) - - - def author(self, author=None, replace=False, **kwargs): - '''Get or set author data. An author element is a dictionary containing a name, - an email address and a URI. Name is mandatory for ATOM, email is mandatory - for RSS. - - This method can be called with: - - - the fields of an author as keyword arguments - - the fields of an author as a dictionary - - a list of dictionaries containing the author fields - - An author has the following fields: - - - *name* conveys a human-readable name for the person. - - *uri* contains a home page for the person. - - *email* contains an email address for the person. - - :param author: Dictionary or list of dictionaries with author data. - :param replace: Add or replace old data. - :returns: List of authors as dictionaries. - - Example:: - - >>> feedgen.author( { 'name':'John Doe', 'email':'jdoe@example.com' } ) - [{'name':'John Doe','email':'jdoe@example.com'}] - - >>> feedgen.author([{'name':'Mr. X'},{'name':'Max'}]) - [{'name':'John Doe','email':'jdoe@example.com'}, - {'name':'John Doe'}, {'name':'Max'}] - - >>> feedgen.author( name='John Doe', email='jdoe@example.com', replace=True ) - [{'name':'John Doe','email':'jdoe@example.com'}] - - ''' - if author is None and kwargs: - author = kwargs - if not author is None: - if replace or self.__atom_author is None: - self.__atom_author = [] - self.__atom_author += ensure_format( author, - set(['name', 'email', 'uri']), set(['name'])) - self.__rss_author = [] - for a in self.__atom_author: - if a.get('email'): - self.__rss_author.append(a['email']) - return self.__atom_author - - - def link(self, link=None, replace=False, **kwargs): - '''Get or set link data. An link element is a dict with the fields href, - rel, type, hreflang, title, and length. Href is mandatory for ATOM. - - This method can be called with: - - the fields of a link as keyword arguments - - the fields of a link as a dictionary - - a list of dictionaries containing the link fields - - A link has the following fields: - - - *href* is the URI of the referenced resource (typically a Web page) - - *rel* contains a single link relationship type. It can be a full URI, - or one of the following predefined values (default=alternate): - - - *alternate* an alternate representation of the entry or feed, for - example a permalink to the html version of the entry, or the front - page of the weblog. - - *enclosure* a related resource which is potentially large in size - and might require special handling, for example an audio or video - recording. - - *related* an document related to the entry or feed. - - *self* the feed itself. - - *via* the source of the information provided in the entry. - - - *type* indicates the media type of the resource. - - *hreflang* indicates the language of the referenced resource. - - *title* human readable information about the link, typically for - display purposes. - - *length* the length of the resource, in bytes. - - RSS only supports one link with URL only. - - :param link: Dict or list of dicts with data. - :param replace: 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 - 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 deleted file mode 100644 index d2fef27..0000000 --- a/feedgen/tests/test_entry.py +++ /dev/null @@ -1,94 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Tests for a basic entry - -These are test cases for a basic entry. -""" - -import unittest -from lxml import etree -from ..feed import FeedGenerator - -class TestSequenceFunctions(unittest.TestCase): - - def setUp(self): - - fg = FeedGenerator() - self.feedId = 'http://example.com' - self.title = 'Some Testfeed' - - fg.id(self.feedId) - fg.title(self.title) - - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The First Episode') - - #Use also the different name add_item - fe = fg.add_item() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Second Episode') - - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') - - self.fg = fg - - def test_checkEntryNumbers(self): - - fg = self.fg - assert len(fg.entry()) == 3 - - def test_checkItemNumbers(self): - - fg = self.fg - assert len(fg.item()) == 3 - - def test_checkEntryContent(self): - - fg = self.fg - assert len(fg.entry()) != None - - def test_removeEntryByIndex(self): - fg = FeedGenerator() - self.feedId = 'http://example.com' - self.title = 'Some Testfeed' - - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') - assert len(fg.entry()) == 1 - fg.remove_entry(0) - assert len(fg.entry()) == 0 - - def test_removeEntryByEntry(self): - fg = FeedGenerator() - self.feedId = 'http://example.com' - self.title = 'Some Testfeed' - - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') - - assert len(fg.entry()) == 1 - fg.remove_entry(fe) - assert len(fg.entry()) == 0 - - def test_categoryHasDomain(self): - fg = FeedGenerator() - fg.title('some title') - fg.link( href='http://www.dontcare.com', rel='alternate' ) - fg.description('description') - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('some title') - fe.category([ - {'term' : 'category', - 'scheme': 'http://www.somedomain.com/category', - 'label' : 'Category', - }]) - - result = fg.rss_str() - assert b'domain="http://www.somedomain.com/category"' in result 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 deleted file mode 100644 index bcfe506..0000000 --- a/feedgen/tests/test_feed.py +++ /dev/null @@ -1,282 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Tests for a basic feed - -These are test cases for a basic feed. -A basic feed does not contain entries so far. -""" - -import unittest -from lxml import etree -from ..feed import FeedGenerator - -class TestSequenceFunctions(unittest.TestCase): - - def setUp(self): - - fg = FeedGenerator() - - self.nsAtom = "http://www.w3.org/2005/Atom" - self.nsRss = "http://purl.org/rss/1.0/modules/content/" - - self.feedId = 'http://lernfunk.de/media/654321' - self.title = 'Some Testfeed' - - self.authorName = 'John Doe' - self.authorMail = 'john@example.de' - self.author = {'name': self.authorName,'email': self.authorMail} - - self.linkHref = 'http://example.com' - self.linkRel = 'alternate' - - self.logo = 'http://ex.com/logo.jpg' - self.subtitle = 'This is a cool feed!' - - self.link2Href = 'http://larskiesow.de/test.atom' - self.link2Rel = 'self' - - 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() diff --git a/feedgen/util.py b/feedgen/util.py deleted file mode 100644 index c7c9454..0000000 --- a/feedgen/util.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- -''' - feedgen.util - ~~~~~~~~~~~~ - - This file contains helper functions for the feed generator module. - - :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. - - :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] - - 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') - - 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 - - -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 diff --git a/feedgen/version.py b/feedgen/version.py deleted file mode 100644 index 70299ee..0000000 --- a/feedgen/version.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -''' - feedgen.version - ~~~~~~~~~~~~~~~ - - :copyright: 2013-2015, Lars Kiesow - - :license: FreeBSD and LGPL, see license.* for more details. - -''' - -'Version of python-feedgen represented as tuple' -version = (0, 3, 2) - - -'Version of python-feedgen represented as string' -version_str = '.'.join([str(x) for x in version]) - -version_major = version[:1] -version_minor = version[:2] -version_full = version - -version_major_str = '.'.join([str(x) for x in version_major]) -version_minor_str = '.'.join([str(x) for x in version_minor]) -version_full_str = '.'.join([str(x) for x in version_full]) 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 new file mode 100644 index 0000000..81e65e4 --- /dev/null +++ b/podgen/__init__.py @@ -0,0 +1,25 @@ +# -*- 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. +""" +# Support for Python 2.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 +from .person import Person +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 new file mode 100644 index 0000000..05371e9 --- /dev/null +++ b/podgen/__main__.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +''' + podgen + ~~~~~~ + + :copyright: 2013, Lars Kiesow and 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 sys +import datetime +import pytz + + +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) + + +def main(): + """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')): + # 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 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.') + print_enc ('') + exit() + + # Remember what type of feed the user wants + arg = sys.argv[1] + + from podgen import Podcast, Person, Media, Category, htmlencode + # Initialize the feed + p = Podcast() + p.name = 'Testfeed' + p.authors.append(Person("Lars Kiesow", "lkiesow@uos.de")) + 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') + p.explicit = False + 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' + 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 + domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas + occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, + 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, + duration= + datetime.timedelta(hours=1, minutes=32, seconds=19)) + + # Should we just print out, or write to file? + if arg == 'rss': + # Print + print_enc(p.rss_str()) + elif arg.endswith('rss'): + # Write to file + p.rss_file(arg, minimize=True) + +if __name__ == '__main__': + main() diff --git a/podgen/category.py b/podgen/category.py new file mode 100644 index 0000000..883c9bd --- /dev/null +++ b/podgen/category.py @@ -0,0 +1,302 @@ +# -*- 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. +""" +# Support for Python 2.7 +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 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. + The category and subcategory will end up properly capitalized and + with unescaped ampersands. + + Example:: + + >>> from podgen 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 + """ + + _legacy_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': [] + } + + _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`. + + :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) + 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 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) + + # 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 available_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)) + + return canonical_category, canonical_subcategory + + @property + def category(self): + """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. + + :type: :obj:`str` + """ + 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/compat.py b/podgen/compat.py similarity index 57% rename from feedgen/compat.py rename to podgen/compat.py index dc9127e..5bd7b57 100644 --- a/feedgen/compat.py +++ b/podgen/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/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 new file mode 100644 index 0000000..881fcb5 --- /dev/null +++ b/podgen/episode.py @@ -0,0 +1,668 @@ +# -*- coding: utf-8 -*- +""" + podgen.episode + ~~~~~~~~~~~~~~ + + :copyright: 2013, Lars Kiesow and 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 warnings + +from lxml import etree +from datetime import datetime +import dateutil.parser +import dateutil.tz + +from podgen.warnings 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 + + +class Episode(object): + """Class representing an episode in a podcast. Corresponds to an RSS Item. + + 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:: + + >>> # 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 can be generated. + + To add an episode to a podcast:: + + >>> import podgen + >>> p = podgen.Podcast() + >>> episode = podgen.Episode() + >>> p.episodes.append(episode) + + You may also replace the last two lines with a shortcut:: + + >>> episode = p.add_episode(podgen.Episode()) + + + .. seealso:: + + :doc:`/usage_guide/episodes` + A friendlier introduction to episodes. + """ + + def __init__(self, **kwargs): + # RSS + self.__authors = [] + 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:`podgen.htmlencode` to fix the text, like this:: + + >>> ep.summary = podgen.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:`.Episode.subtitle` and + :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`. 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. + + :type: :obj:`str` which can be parsed as XHTML. + :RSS: content:encoded or description + """ + + self.__media = 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). + + :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'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. + + :type: :obj:`str` + :RSS: title + """ + + # 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 = False + """Whether this podcast includes a video episode with embedded `closed + captioning`_ support. Defaults to ``False``. + + :type: :obj:`bool` + :RSS: itunes:isClosedCaptioned + + .. _closed captioning: https://en.wikipedia.org/wiki/Closed_captioning + """ + + self.__position = None + + self.__episode_number = None + + self.__season = None + + self.__episode_type = EPISODE_TYPE_FULL + + self.subtitle = None + """A short subtitle. + + This is shown in the Description column in iTunes. + 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): + 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 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/' + + entry = etree.Element('item') + + if not (self.title or self.summary): + raise ValueError('Required fields not set, make sure either ' + 'title or summary is set!') + + if self.title: + title = etree.SubElement(entry, 'title') + title.text = self.title + + if self.link: + link = etree.SubElement(entry, 'link') + link.text = self.link + + 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] + 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.__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 []: + author = etree.SubElement(entry, '{%s}creator' % DUBLIN_NS) + 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 and with email, so use rss author + 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: + rss_guid = self.__media.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 = rss_guid + guid.attrib['isPermaLink'] = 'false' + + if self.__media: + enclosure = etree.SubElement(entry, 'enclosure') + enclosure.attrib['url'] = self.__media.url + 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) + + 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.__image: + image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) + image.attrib['href'] = self.__image + + if self.__explicit is not None: + explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) + explicit.text = "Yes" if self.__explicit else "No" + + if self.is_closed_captioned: + is_closed_captioned = etree.SubElement(entry, + '{%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.__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) + + 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 + + return entry + + @property + 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. They're usually + not displayed anywhere. + + .. 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:`~podgen.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:: + + >>> # 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")] + + :type: :obj:`list` of :class:`podgen.Person` + :RSS: author or dc:creator, and itunes:author + """ + return self.__authors + + @authors.setter + def authors(self, authors): + try: + 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) + + @property + def publication_date(self): + """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:: + + 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 + + @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): + raise ValueError('Invalid datetime format') + if publication_date.tzinfo is None: + raise ValueError('Datetime object has no timezone info') + self.__publication_date = publication_date + else: + self.__publication_date = None + + @property + 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 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 + + @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"): + # It's a duck + self.__media = media + else: + raise TypeError("The parameter media must have the attributes " + "url, size and type.") + else: + self.__media = None + + @property + def withhold_from_itunes(self): + """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 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 + + @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, 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"; a + :class:`.NotSupportedByItunesWarning` will be issued if it doesn't. + + :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 that you use Garageband's Enhanced + Podcast feature. + + The podcast's image is used if this isn't supported. + """ + 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'))): + warnings.warn('Image filename must end with png or jpg, not ' + '%s' % image.split(".")[-1], NotSupportedByItunesWarning, + stacklevel=2) + self.__image = image + else: + self.__image = None + + @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, 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 + episode, and a "clean" graphic will appear. + + :type: :obj:`bool` + :RSS: itunes:explicit + """ + return self.__explicit + + @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 + + @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 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. + + 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 + :attr:`publication date <.Episode.publication_date>`. + + To remove the order from the episode, set the position back to + :obj:`None`. + + :type: :obj:`int` + :RSS: itunes:order + """ + return self.__position + + @position.setter + def position(self, position): + if position is not None: + 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/media.py b/podgen/media.py new file mode 100644 index 0000000..8270cc0 --- /dev/null +++ b/podgen/media.py @@ -0,0 +1,534 @@ +# -*- 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. +""" +# 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 +import datetime + +from tinytag import TinyTag +import requests + +from podgen.warnings import NotSupportedByItunesWarning +from podgen import version + + +def _get_new_requests_session(): + # 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 + + +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 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:: + + 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:`~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`. + + Media types supported by iTunes: + + * Audio + * M4A + * MP3 + * Video + * MOV + * MP4 + * M4V + * Document + * 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) and duration + which is optional (but recommended). + + .. seealso:: + :ref:`podgen.Media-guide` + for a more gentle introduction. + """ + 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, duration=None, + requests_session=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 + 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): + """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 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 + + @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, + 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, + stacklevel=2) + self._url = url + + @property + def file_extension(self): + """The file extension of :attr:`~.Media.url`. Read-only. + + :type: :obj:`str` + """ + return '.' + urlparse(self.url).path.split('.')[-1] + + @property + def size(self): + """The media's file size in bytes. + + 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 accesses, 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 + 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.", + stacklevel=3) + 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): + """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): + """The MIME type of this media. + + 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.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.get_type`. + """ + return self._type + + @type.setter + def type(self, type): + if not type: + raise ValueError("Type cannot be empty or None") + + type = type.strip().lower() + + if type not in self.file_types.values(): + warnings.warn("Media type %s is not supported by iTunes." % type, + NotSupportedByItunesWarning, stacklevel=2) + self._type = type + + def get_type(self, 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 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 + + :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].lower() + try: + return self.file_types[file_extension] + except KeyError as 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): + """The duration of the media file. + + :type: :class:`datetime.timedelta` + :raises: :obj:`TypeError` if you try to assign anything other than + :class:`datetime.timedelta` or :obj:`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`, then this will be :obj:`None` as + well. + + :type: :obj:`str` + """ + 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, 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. + + Example (assuming the server responds with Content-Length: 252345991 and + Content-Type: audio/mpeg):: + + >>> from podgen import Media + >>> # Assume an episode is hosted at example.com + >>> 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 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 + :param duration: The media's duration. + :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): + 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: + 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, duration) + + def __str__(self): + return "Media(url=%s, size=%s, type=%s, duration=%s)" % \ + (self.url, self.size, self.type, self.duration) + + 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. + + 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 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 = self.requests_session.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() + + def populate_duration_from(self, filename): + """Populate :attr:`.Media.duration` by analyzing the given file. + + 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, + 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.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, + 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): + """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. + """ + filename = None + try: + with tempfile.NamedTemporaryFile( + delete=False, suffix=self.file_extension) as fd: + filename = fd.name + self.download(fd) + self.populate_duration_from(filename) + finally: + if filename: + os.remove(filename) diff --git a/podgen/not_supported_by_itunes_warning.py b/podgen/not_supported_by_itunes_warning.py new file mode 100644 index 0000000..e21c539 --- /dev/null +++ b/podgen/not_supported_by_itunes_warning.py @@ -0,0 +1,11 @@ +# Kept for backwards compatibility +from podgen.warnings import NotSupportedByItunesWarning + +import warnings +warnings.warn( + "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 +) diff --git a/podgen/person.py b/podgen/person.py new file mode 100644 index 0000000..fd7fcaf --- /dev/null +++ b/podgen/person.py @@ -0,0 +1,116 @@ +# -*- 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. +""" +# 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. + + A Person can represent both real persons and less personal entities like + organizations. 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. + + .. warning:: + + **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 + Podcast or Episode.) + + Example of use:: + + >>> from podgen 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. + + 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. + + :type: :obj:`str` + """ + 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. + + :type: :obj:`str` + """ + 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/podgen/podcast.py b/podgen/podcast.py new file mode 100644 index 0000000..41e96a3 --- /dev/null +++ b/podgen/podcast.py @@ -0,0 +1,1209 @@ +# -*- coding: utf-8 -*- +""" + podgen.feed + ~~~~~~~~~~~~ + + :copyright: 2013, Lars Kiesow and 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 + +from lxml import etree +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, \ + htmlencode +from podgen.person import Person +import podgen.version +import sys +from podgen.compat import string_types +import collections +import inspect +import warnings + + +_feedgen_version = podgen.version.version_str + + +class Podcast(object): + """Class representing one podcast feed. + + The following attributes are mandatory: + + * :attr:`~podgen.Podcast.name` + * :attr:`~podgen.Podcast.website` + * :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. As an example:: + + >>> import podgen + >>> # 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, **kwargs): + self.__episodes = [] + """The list used by self.episodes.""" + 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: + self.name = None + """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. + + :type: :obj:`str` + :RSS: title + """ + + self.website = None + """The absolute URL of this podcast's website. + + This is one of the mandatory attributes. + + :type: :obj:`str` + :RSS: link + """ + + self.description = None + """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. + + 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. + + :type: :obj:`bool` + :RSS: itunes:explicit + """ + + # Optional: + self.__cloud = 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. + + :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 PodGen. + + :type: :obj:`str` + :RSS: generator + + .. 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 + + :type: :obj:`str` + :RSS: language + """ + + 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 + """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 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: :obj:`bool` + :RSS: itunes:block + """ + + self.__category = None + + self.__image = None + + self.__complete = None + + self.new_feed_url = None + """When set, tell iTunes that your feed has moved to this URL. + + 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. + 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 you set is correct, or else you're making + people switch to a URL that doesn't work! + """ + + self.__owner = 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. + + :type: :obj:`str` + :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"`` + + .. warning:: + + 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:: + The :doc:`guide on how to use PubSubHubbub ` + A step-for-step guide with examples. + + .. _PubSubHubbub: https://en.wikipedia.org/wiki/PubSubHubbub + """ + + 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. + + .. _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 :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:: + + To preserve backwards compatibility, no RSS element will be + produced when this is set to :data:`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): + 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 :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 + + @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_class(self): + """Class used to represent episodes. + + This is used by :py:meth:`.add_episode` when creating new episode + objects, and you, too, may use it when creating episodes. + + By default, this property points to :py:class:`.Episode`. + + 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 + + @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 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: :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:: + + ... + >>> episode1 = p.add_episode() + >>> episode1.title = 'First episode' + >>> # You may also provide an episode object yourself: + >>> 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 + type of objects are created by changing + :attr:`~podgen.Episode.episode_class`. + + """ + if new_episode is None: + new_episode = self.episode_class() + self.episodes.append(new_episode) + return new_episode + + def _create_rss(self): + """Create an RSS feed XML structure containing all previously set fields. + + :returns: The root element (ie. the rss element) of the feed. + :rtype: lxml.etree.Element + """ + ITUNES_NS = self._nsmap['itunes'] + + 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): + missing = ', '.join(([] if self.name else ['name']) + + ([] if self.website else ['website']) + + ([] if self.description else ['description']) + + ([] 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 + link = etree.SubElement(channel, 'link') + link.text = self.website + desc = etree.SubElement(channel, 'description') + desc.text = self.description + 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') + 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.__cloud.get('protocol') + if self.copyright: + copyright = etree.SubElement(channel, 'copyright') + copyright.text = self.copyright + if self.__docs: + docs = etree.SubElement(channel, 'docs') + docs.text = self.__docs + if self.generator: + generator = etree.SubElement(channel, 'generator') + generator.text = self.generator + if self.language: + language = etree.SubElement(channel, 'language') + language.text = self.language + + if self.last_updated is None: + lastBuildDateDate = datetime.now(dateutil.tz.tzutc()) + else: + 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 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: + # Use dc:creator, since it supports multiple authors (and + # author without email) + for a in self.authors or []: + author = etree.SubElement(channel, + '{%s}creator' % self._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: + # Only one author and with email, so use rss managingEditor + author = etree.SubElement(channel, 'managingEditor') + 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] + if episode_dates: + actual_pubDate = max(episode_dates) + else: + actual_pubDate = None + else: + actual_pubDate = self.publication_date + if actual_pubDate: + pubDate = etree.SubElement(channel, 'pubDate') + 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') + day.text = d + 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) + + if self.withhold_from_itunes: + block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) + block.text = 'Yes' + + if self.category: + category = etree.SubElement(channel, '{%s}category' % ITUNES_NS) + category.attrib['text'] = self.category.category + if self.category.subcategory: + subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) + subcategory.attrib['text'] = self.category.subcategory + + if self.image: + image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) + image.attrib['href'] = self.image + + if self.complete: + complete = etree.SubElement(channel, '{%s}complete' % ITUNES_NS) + complete.text = "Yes" + + 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 + + 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_email = etree.SubElement(owner, '{%s}email' % ITUNES_NS) + owner_email.text = self.owner.email + + if self.subtitle: + subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) + subtitle.text = self.subtitle + + if self.feed_url: + 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' % self._nsmap['atom']) + link_to_hub.attrib['href'] = self.pubsubhubbub + 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) + + 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="UTF-8").decode("UTF-8") + + 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, minimize=False, encoding='UTF-8', + xml_declaration=True): + """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 (default: False). + :type minimize: bool + :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` (unicode in 2.7) + """ + feed = self._create_rss() + 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 (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 + readability (default: False). + :type minimize: bool + :param encoding: Encoding used in the XML file (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: Nothing. + """ + 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", encoding=encoding) 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 + 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 + 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 + + @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 + + @property + def cloud(self): + """The cloud data of the feed, as a 5-tuple. It specifies a web service + 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)``. + + :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.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 + choice if you're looking to set up a new service of this kind. + """ + 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} + else: + self.__cloud = None + + def set_generator(self, generator=None, version=None, uri=None, + exclude_podgen=False): + """Set the generator of the feed, formatted nicely, which identifies the + 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. + :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:: + + The attribute :py:attr:`.generator` + Lets you access and set the generator string yourself, without + any formatting help. + """ + self.generator = self._program_name_to_str(generator, version, uri) + \ + (" (using %s)" % self._feedgen_generator_str + if not exclude_podgen else "") + + 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( + podgen.version.name, + podgen.version.version_full, + podgen.version.website + ) + + + @property + def authors(self): + """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:`~podgen.person.Person` object to this + attribute:: + + >>> # 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")] + + The authors don't need to have both name and email set. The names are + shown under the podcast's title on iTunes. + + The initial value is an empty list, so you can use the list methods + right away. + + Example:: + + >>> # 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")] + + :type: :obj:`list` of :class:`podgen.Person` + :RSS: managingEditor or dc:creator, and itunes:author + """ + return self.__authors + + @authors.setter + def authors(self, authors): + try: + 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) + + @property + def publication_date(self): + """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 + + @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 not isinstance(publication_date, datetime): + raise ValueError('Invalid datetime format') + elif publication_date.tzinfo is None: + raise ValueError('Datetime object has no timezone info') + self.__publication_date = publication_date + + @property + def skip_hours(self): + """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 stop refreshing the feed between 18 and 7:: + + >>> from podgen import Podcast + >>> p = Podcast() + >>> p.skip_hours = set(range(18, 24)) + >>> p.skip_hours + {18, 19, 20, 21, 22, 23} + >>> 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 + + @skip_hours.setter + def skip_hours(self, hours): + if hours is not None: + if not (isinstance(hours, list) or isinstance(hours, set)): + hours = set(hours) + for h in hours: + if h not in range(24): + raise ValueError('Invalid hour %s' % h) + self.__skip_hours = hours + + @property + def skip_days(self): + """Set of days in which podcatchers don't need to refresh this feed. + + 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 stop refreshing the feed in the weekend:: + + >>> from podgen import Podcast + >>> p = Podcast() + >>> p.skip_days = {"Friday", "Saturday", "sUnDaY"} + >>> p.skip_days + {"Saturday", "Friday", "Sunday"} + + :type: :obj:`set` of :obj:`str` + :RSS: skipDays + """ + 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 = set(day.capitalize() for day in days) + else: + self.__skip_days = None + + @property + 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 + + @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 + + @property + def category(self): + """The iTunes category, which appears in the category column + and in iTunes Store listings. + + :type: :class:`podgen.Category` + :RSS: itunes:category + """ + 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"): + self.__category = category + else: + raise TypeError("A Category(-like) object must be used, got " + "%s" % category) + else: + self.__category = None + + @property + def image(self): + """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 they + don't, a :class:`.NotSupportedByItunesWarning` will be issued. + + :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 + + @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'))): + warnings.warn\ + ( + 'Image URL must end with png or jpg, not ' + '%s' % image.split(".")[-1], NotSupportedByItunesWarning, + stacklevel=2 + ) + self.__image = image + else: + self.__image = None + + @property + def complete(self): + """Whether this podcast is completed or not. + + 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. + + :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 + someday. + + """ + return self.__complete + + @complete.setter + def complete(self, complete): + if complete is not None: + self.__complete = bool(complete) + else: + self.__complete = None + + @property + def owner(self): + """The :class:`~podgen.Person` who owns this podcast. iTunes + 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 + + @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.') + else: + self.__owner = None + + @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 + attribute if you're able to. + + :type: :obj:`str` + :RSS: atom:link with ``rel="self"`` + """ + 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://', + '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.__feed_url = feed_url 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/podgen/tests/test_category.py b/podgen/tests/test_category.py new file mode 100644 index 0000000..9b2a7c6 --- /dev/null +++ b/podgen/tests/test_category.py @@ -0,0 +1,117 @@ +# -*- 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. +""" +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + +import unittest +import warnings +import sys + +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): + # 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") + 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", "Fiction") + self.assertEqual(c.category, "Arts") + + self.assertRaises(AttributeError, setattr, c, "subcategory", "Science Fiction") + self.assertEqual(c.subcategory, "Food") + + def test_escapedIsAccepted(self): + 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/tests/test_episode.py b/podgen/tests/test_episode.py new file mode 100644 index 0000000..91eddda --- /dev/null +++ b/podgen/tests/test_episode.py @@ -0,0 +1,719 @@ +# -*- coding: utf-8 -*- +""" + podgen.tests.test_episode + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + Test the Episode class. + + :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 + +from lxml import etree + +from podgen import Person, Media, Podcast, htmlencode, Episode, \ + NotSupportedByItunesWarning, EPISODE_TYPE_FULL, EPISODE_TYPE_BONUS, \ + EPISODE_TYPE_TRAILER +import datetime +import pytz +from dateutil.parser import parse as parsedate + + +class TestBaseEpisode(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' + self.description = 'A cool tent' + self.explicit = False + + 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' + fe.title = 'The First Episode' + self.fe = fe + + #Use also the list directly + fe = Episode() + fg.episodes.append(fe) + fe.id = 'http://lernfunk.de/media/654321/2' + fe.title = 'The Second Episode' + + fe = fg.add_episode() + fe.id = 'http://lernfunk.de/media/654321/3' + fe.title = 'The Third Episode' + + 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!" + 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 + episode_number = 4 + + 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, + episode_number=episode_number, + ) + + # 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) + 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 + + def test_removeEntryByIndex(self): + fg = Podcast() + self.feedId = 'http://example.com' + self.title = 'Some Testfeed' + + fe = fg.add_episode() + 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 + + def test_removeEntryByEntry(self): + fg = Podcast() + self.feedId = 'http://example.com' + self.title = 'Some Testfeed' + + fe = fg.add_episode() + 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 = 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 = Episode() + 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 = Episode() + 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 = \ + datetime.datetime(2015, 1, 1, 15, 0, tzinfo=pytz.utc) + self.fg.episodes[1].publication_date = \ + datetime.datetime(2016, 1, 3, 12, 22, tzinfo=pytz.utc) + 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 + + def test_feedPubDateNotOverriddenByEpisode(self): + 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 + + new_date = datetime.datetime(2016, 1, 2, 3, 4, tzinfo=pytz.utc) + 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 + assert parsedate(pubDate.text) == new_date + + 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 + 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.authors = [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 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.authors.append(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.authors.extend([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): + person1 = Person("John Doe", "johndoe@example.org") + person2 = Person("Mary Sue", "marysue@example.org") + + 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] + + # Test that both authors are included, in the same order they were added + 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 includes all authors' name, but not email + itunes_author = \ + self.fe.rss_entry().find("{%s}author" % self.itunes_ns).text + 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 + + def test_authorsInvalidAssignment(self): + self.assertRaises(TypeError, self.do_authorsInvalidAssignment) + + 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.media = 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, 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 + + 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 + + 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" == 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" == 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" == d.text + ce = self.fe.rss_entry().find(content_encoded) + assert ce is not None + 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" == 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" == d.text + + 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) + 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 + + 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) + + 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_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()\ + .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_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()\ + .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 + 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" + + with self.assertRaises(ValueError): + self.fe.episode_type = False + + 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) diff --git a/podgen/tests/test_media.py b/podgen/tests/test_media.py new file mode 100644 index 0000000..7ceb4ea --- /dev/null +++ b/podgen/tests/test_media.py @@ -0,0 +1,381 @@ +# -*- 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. +""" +# 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 +import unittest +import warnings +from datetime import timedelta +import mock +import io + +from podgen import Media, NotSupportedByItunesWarning +import podgen.media + + +class TestMedia(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") + self.duration = timedelta(hours=1, minutes=32, seconds=44) + warnings.simplefilter("always") + def noop(*args, **kwargs): + pass + warnings.showwarning = noop + + 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_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" + 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) + another_size = 1234567 + 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" + + # Mapping between url file extension and type given by iTunes + # 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"]), + '.m4v': set(["video/x-m4v"]), + '.pdf': set(["application/pdf"]), + '.epub': set(["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: + 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)) + + 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 + + class MyLittleRequests(object): + @staticmethod + def head(*args, **kwargs): + assert args[0] == url + assert kwargs['allow_redirects'] == True + assert 'timeout' in kwargs + + class MyLittleResponse(object): + headers = { + 'Content-Type': type, + 'Content-Length': size, + } + + @staticmethod + def raise_for_status(): + pass + + return MyLittleResponse + + 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) + 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.requests_session = mock_requests + m.fetch_duration() + 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 + 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) + 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 + 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) + m.requests_session = MyLittleRequests + fd = io.BytesIO() + m.download(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(filename) + with open(filename, "rb") as fd: + self.assertEqual(fd.read().decode("UTF-8"), "binary content") + 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) + + @mock.patch("podgen.media.requests", autospec=True) + 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 + 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'] + + @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) + + @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) + diff --git a/podgen/tests/test_person.py b/podgen/tests/test_person.py new file mode 100644 index 0000000..99c9726 --- /dev/null +++ b/podgen/tests/test_person.py @@ -0,0 +1,90 @@ +# -*- 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. +""" +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + +import unittest +from podgen import Person + +class TestPerson(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/podgen/tests/test_podcast.py b/podgen/tests/test_podcast.py new file mode 100644 index 0000000..fa81180 --- /dev/null +++ b/podgen/tests/test_podcast.py @@ -0,0 +1,639 @@ +# -*- coding: utf-8 -*- +""" + podgen.tests.test_podcast + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + Test the Podcast alone, without any Episode objects. + + :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 raise_from + +import unittest +import warnings +import locale + +from lxml import etree +import tempfile +import os + +from podgen import NotSupportedByItunesWarning, Person, Category, Podcast +import podgen.version +import datetime +import dateutil.tz +import dateutil.parser + +class TestPodcast(unittest.TestCase): + + def setUp(self): + self.existing_locale = locale.setlocale(locale.LC_ALL, None) + locale.setlocale(locale.LC_ALL, 'C') + + fg = Podcast() + + 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.feed_url = "http://example.com/feeds/myfeed.rss" + + self.name = 'Some Testfeed' + + # 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!' + self.subtitle = 'Coolest of all' + + self.language = 'en' + + self.cloudDomain = 'example.com' + self.cloudPort = '4711' + self.cloudPath = '/ws/example' + 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" + self.docs = 'http://www.rssboard.org/rss-specification' + self.skip_days = set(['Tuesday']) + self.skip_hours = set([23]) + + self.explicit = False + + self.programname = podgen.version.name + + self.web_master = Person(email='webmaster@example.com') + 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" + self.xslt = "http://example.com/feed/stylesheet.xsl" + + + 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) + fg.pubsubhubbub = self.pubsubhubbub + fg.copyright = self.copyright + fg.authors.append(self.author) + 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 + fg.complete = self.complete + fg.new_feed_url = self.new_feed_url + fg.xslt = self.xslt + + self.fg = fg + + warnings.simplefilter("always") + 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( + 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), + pubsubhubbub=self.pubsubhubbub, + 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, + new_feed_url=self.new_feed_url, + xslt=self.xslt, + ) + # 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.web_master + + 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 + assert fg.image == self.image + 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 + 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 + encoding = 'UTF-8' + 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, encoding=encoding, **kwargs) + # Read the resulting RSS + with open(filename, "r", encoding=encoding) as myfile: + rssString = myfile.read() + 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 + return 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 + nsAtom = "http://www.w3.org/2005/Atom" + + channel = feed.find("channel") + assert channel != None + + 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 + 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 self.author.email in channel.find("managingEditor").text + 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 + + 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) + 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" + 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", + "example.com/feed.rss") + + def test_generator(self): + software_name = "My Awesome Software" + software_version = (1, 0) + software_url = "http://example.com/awesomesoft/" + + # 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-podgen + 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 + + # 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-podgen + 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 + + def test_str(self): + assert str(self.fg) == self.fg.rss_str( + minimize=False, + encoding="UTF-8", + 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.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 + 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.authors = [Person(None, "justan@email.address")] + channel = self.fg._create_rss().find("channel") + # managingEditor uses 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? + 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.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.authors[0].name + # itunes:author equals name? + assert channel.find("{%s}author" % self.nsItunes).text == \ + self.fg.authors[0].name + + def test_AuthorNameAndEmail(self): + # Both name and email - use managingEditor and itunes:author, + # not dc:creator + 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.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.authors[0].name + + def test_multipleAuthors(self): + # Multiple authors - use itunes:author and dc:creator, not + # managingEditor. + + person1 = Person("Multiple", "authors@example.org") + person2 = Person("Are", "cool@example.org") + self.fg.authors = [person1, person2] + 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_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.web_master = Person(None, "justan@email.address") + channel = self.fg._create_rss().find("channel") + assert channel.find("webMaster").text == self.fg.web_master.email + + 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") + 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 + ")", + channel.find("webMaster").text) + + def test_categoryWithoutSubcategory(self): + c = Category("Arts") + 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 + + 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.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, setattr, self.fg, "category", c) + + def test_explicitIsExplicit(self): + 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 + 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.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 = set([ + "description", + "title", + "link", + "explicit", + ]) + + for test_property in mandatory_properties: + fg = Podcast() + if test_property != "description": + fg.description = self.description + if test_property != "title": + fg.name = self.name + if test_property != "link": + fg.website = self.website + if test_property != "explicit": + fg.explicit = self.explicit + try: + self.assertRaises(ValueError, fg._create_rss) + except AssertionError as e: + raise_from(AssertionError( + "The test failed for %s" % test_property), 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 + + 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 + + # 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 = "" + + # Test that its contents is correct + self.assertEqual(podcast_type.text, "serial") + +if __name__ == '__main__': + unittest.main() diff --git a/podgen/tests/test_util.py b/podgen/tests/test_util.py new file mode 100644 index 0000000..75db31a --- /dev/null +++ b/podgen/tests/test_util.py @@ -0,0 +1,40 @@ +# -*- 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. +""" +# Support for Python 2.7 +from __future__ import absolute_import, division, print_function, unicode_literals +from builtins import * + +import unittest +from podgen import util + +class TestUtil(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/podgen/util.py b/podgen/util.py new file mode 100644 index 0000000..8e64e2c --- /dev/null +++ b/podgen/util.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +""" + podgen.util + ~~~~~~~~~~~~ + + This file contains helper functions for the feed generator module. + + :copyright: 2013, Lars Kiesow and 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 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. + + :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. + """ + # TODO: Check if this function is obsolete and perhaps remove it + 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] + + 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') + + 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 + + +def formatRFC2822(d): + """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') + 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): + """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] + + if length == 0: + return "(empty)" + elif length == 1: + return l[0] + else: + return ", ".join(l[:-1]) + " and " + l[-1] diff --git a/podgen/version.py b/podgen/version.py new file mode 100644 index 0000000..3786011 --- /dev/null +++ b/podgen/version.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +""" + podgen.version + ~~~~~~~~~~~~~~~ + + :copyright: 2013-2015, Lars Kiesow and 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 * + +'Version of python-podgen represented as tuple' +version = (1, 1, 0) + + +'Version of python-podgen represented as string' +version_str = '.'.join([str(x) for x in version]) + +version_major = version[:1] +version_minor = version[:2] +version_full = version + +version_major_str = '.'.join([str(x) for x in version_major]) +version_minor_str = '.'.join([str(x) for x in version_minor]) +version_full_str = '.'.join([str(x) for x in version_full]) + +'Name of this project' +name = "python-podgen" + +'Website of this project' +website = "https://podgen.readthedocs.org" 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 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 c7c00df..d1a8747 100644 --- a/readme.md +++ b/readme.md @@ -1,13 +1,12 @@ -============= -Feedgenerator -============= +PodGen (forked from python-feedgen) +=================================== -[![Build Status](https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master) -](https://travis-ci.org/lkiesow/python-feedgen) +[![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 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, and is +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 @@ -15,166 +14,21 @@ 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 ------------- - -**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 - - -------------- -Create a Feed -------------- - -To create a feed simply instanciate the FeedGenerator class and insert some -data:: - - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() - >>> fg.id('http://lernfunk.de/media/654321') - >>> fg.title('Some Testfeed') - >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) - >>> fg.link( href='http://example.com', rel='alternate' ) - >>> fg.logo('http://ex.com/logo.jpg') - >>> fg.subtitle('This is a cool feed!') - >>> fg.link( href='http://larskiesow.de/test.atom', rel='self' ) - >>> fg.language('en') - -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 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 - - ----------------- -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:: - - >>> 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. - ----------- -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) - -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. - -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 -feed with some additional elements for ITunes. - -To produce a podcast simply load the `podcast` extension:: - - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() - >>> fg.load_extension('podcast') - ... - >>> fg.podcast.itunes_category('Technology', 'Podcasting') - ... - >>> fe = fg.add_entry() - >>> fe.id('http://lernfunk.de/media/654321/1/file.mp3') - >>> fe.title('The First Episode') - >>> fe.description('Enjoy our first episode.') - >>> fe.enclosure('http://lernfunk.de/media/654321/1/file.mp3', 0, 'audio/mpeg') - ... - >>> fg.rss_str(pretty=True) - >>> fg.rss_file('podcast.xml') - -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`. - +- Repository: https://github.com/tobinus/python-podgen +- Documentation: https://podgen.readthedocs.io/ +- Python Package Index: https://pypi.python.org/pypi/podgen/ ---------------------- -Testing the Generator ---------------------- -You can test the module by simply executing:: +See the documentation link above for installation instructions and +guides on how to use this module. - $ python -m feedgen +Known bugs and limitations +-------------------------- -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). +* The updates to Apple's podcasting guidelines since 2016 have not been + 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 + hexadecimal character reference (eg. `<`), simply because lxml provides + no documentation on how to do that when using the text property. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f6c41e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# Packages required by users of this library belong in setup.py +# Install using setup.py: +-e . +# Packages needed for development: +mock +Sphinx diff --git a/setup.py b/setup.py index 059dcb7..1eef569 100755 --- a/setup.py +++ b/setup.py @@ -1,48 +1,57 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from distutils.core import setup -import feedgen.version +from setuptools import setup 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 = '''\ -Feedgenerator -============= + name = 'podgen', + packages = ['podgen'], + # Remember to update the version in podgen.version, too! + version = '1.1.0', + description = 'Generating podcasts with Python should be easy!', + author = 'Thorben W. S. Dahl', + author_email = 'thorben@sjostrom.no', + url = 'http://podgen.readthedocs.io/en/latest/', + keywords = ['feed', 'RSS', 'podcast', 'iTunes', 'generator'], + 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', + '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 :: 2.7', + 'Programming Language :: Python :: 3', + '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', + 'Topic :: Text Processing', + 'Topic :: Text Processing :: Markup', + 'Topic :: Text Processing :: Markup :: XML' + ], + long_description = '''\ +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! -It is licensed under the terms of both, the FreeBSD license and the LGPLv3+. +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+. Choose the one which is more convenient for you. For more details have a look at license.bsd and license.lgpl. '''