diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..c573c55
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,46 @@
+name: Tests
+
+on:
+ - push
+ - pull_request
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: set up python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.10'
+
+ - name: install system dependencies
+ run: |
+ sudo apt update
+ sudo apt install python3-lxml python3-dateutil
+
+ - name: install Python dependencies
+ run: |
+ pip install bandit flake8 coverage liccheck
+
+ - name: install feedgen
+ run: |
+ python setup.py install
+
+ - name: run linter
+ run: make test
+
+ - name: run license check
+ run: liccheck -s .licenses.ini
+
+ - name: run tests
+ run: |
+ python -m feedgen
+ python -m feedgen atom
+ python -m feedgen rss
+
+
+ - name: run coverage
+ run: coverage report --fail-under=93
+
diff --git a/.gitignore b/.gitignore
index ce30c5f..af64cfd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,17 @@
+.idea/
venv
*.pyc
*.pyo
*.swp
+
+feedgen/tests/tmp_Atomfeed.xml
+
+feedgen/tests/tmp_Rssfeed.xml
+
+tmp_Atomfeed.xml
+
+tmp_Rssfeed.xml
+
+# testing artifacts
+.coverage
+*.egg-info/
diff --git a/.licenses.ini b/.licenses.ini
new file mode 100644
index 0000000..cac5032
--- /dev/null
+++ b/.licenses.ini
@@ -0,0 +1,9 @@
+# Authorized licenses in lower case
+
+# There is no project rule against adding new licenses as long as they are
+# compatible with the project's license.
+
+[Licenses]
+authorized_licenses:
+ BSD
+ MIT
diff --git a/MANIFEST.in b/MANIFEST.in
index f27e442..a7c625e 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,3 @@
-include license.bsd license.lgpl readme.md
+include license.bsd license.lgpl readme.rst
recursive-include docs *.html *.css *.png *.gif *.js
+recursive-include tests *.py
diff --git a/Makefile b/Makefile
index ff99d34..a4c34de 100644
--- a/Makefile
+++ b/Makefile
@@ -1,15 +1,20 @@
sdist: doc
python setup.py sdist
+bdist_wheel: doc
+ python setup.py bdist_wheel
+
clean: doc-clean
@echo Removing binary files...
@rm -f `find feedgen -name '*.pyc'`
@rm -f `find feedgen -name '*.pyo'`
+ @rm -rf feedgen.egg-info/ build/
@echo Removing source distribution files...
@rm -rf dist/
@rm -f MANIFEST
+ @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml
-doc: doc-clean doc-html doc-man doc-latexpdf
+doc: doc-clean doc-html doc-man
doc-clean:
@echo Removing docs...
@@ -40,5 +45,10 @@ 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:
+ twine upload dist/*
+
+test:
+ coverage run --source=feedgen -m unittest discover -s tests
+ flake8 $$(find setup.py tests feedgen -name '*.py')
+ bandit -r feedgen
diff --git a/SECURITY.rst b/SECURITY.rst
new file mode 100644
index 0000000..7af7bb8
--- /dev/null
+++ b/SECURITY.rst
@@ -0,0 +1,17 @@
+Security Policy
+===============
+
+Supported Versions
+------------------
+
+Only the latest version of this library is supported.
+We are doing our best to make updates as easy as possible
+so that keeping up-to-date is usually pretty easy.
+
+
+Reporting a Vulnerability
+-------------------------
+
+If you find a security vulnerability,
+please report it by sending a mail to security@lkiesow.de.
+We will discuss the problem internally and, if necessary, release a patched version as soon as possible.
diff --git a/doc/api.entry.rst b/doc/api.entry.rst
index eab7d2f..bbc0c3a 100644
--- a/doc/api.entry.rst
+++ b/doc/api.entry.rst
@@ -1,5 +1,6 @@
.. raw:: html
+
Contents
diff --git a/doc/api.feed.rst b/doc/api.feed.rst
index bc165c0..228d524 100644
--- a/doc/api.feed.rst
+++ b/doc/api.feed.rst
@@ -1,5 +1,6 @@
.. raw:: html
+
Contents
diff --git a/doc/api.rst b/doc/api.rst
index be89075..9991a6d 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -14,5 +14,7 @@ Contents:
api.entry
api.util
ext/api.ext.base
+ ext/api.ext.dc
ext/api.ext.podcast
ext/api.ext.podcast_entry
+ ext/api.ext.torrent
diff --git a/doc/api.util.rst b/doc/api.util.rst
index 47747d5..8042022 100644
--- a/doc/api.util.rst
+++ b/doc/api.util.rst
@@ -1,5 +1,6 @@
.. raw:: html
+
Contents
diff --git a/doc/conf.py b/doc/conf.py
index 0795320..4df01a6 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -3,7 +3,10 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
-import sys, os, time, codecs, re
+import sys
+import os
+import codecs
+import re
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
@@ -13,18 +16,18 @@
import feedgen.version
-# -- General configuration -----------------------------------------------------
+# -- General configuration ----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
-# Add any Sphinx extension module names here, as strings. They can be extensions
-# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
- 'sphinx.ext.intersphinx',
- 'sphinx.ext.coverage',
- 'sphinx.ext.autodoc'
- ]
+ 'sphinx.ext.intersphinx',
+ 'sphinx.ext.coverage',
+ 'sphinx.ext.autodoc'
+ ]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -40,7 +43,7 @@
# General information about the project.
project = u'python-feedgen'
-copyright = u'2013, Lars Kiesow'
+copyright = u'2013-2016, Lars Kiesow'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@@ -65,7 +68,8 @@
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
-# The reST default role (used for this markup: `text`) to use for all documents.
+# The reST default role (used for this markup: `text`) to use for all
+# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
@@ -86,7 +90,7 @@
#modindex_common_prefix = []
-# -- Options for HTML output ---------------------------------------------------
+# -- Options for HTML output --------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
@@ -169,24 +173,24 @@
htmlhelp_basename = 'pyFeedGen'
-# -- Options for LaTeX output --------------------------------------------------
+# -- Options for LaTeX output -------------------------------------------------
latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
+ # The paper size ('letterpaper' or 'a4paper').
+ #'papersize': 'letterpaper',
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
+ # The font size ('10pt', '11pt' or '12pt').
+ #'pointsize': '10pt',
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
+ # Additional stuff for the LaTeX preamble.
+ #'preamble': '',
}
-# Grouping the document tree into LaTeX files. List of tuples
-# (source start file, target name, title, author, documentclass [howto/manual]).
+# Grouping the document tree into LaTeX files. List of tuples (source start
+# file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
- ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation',
- u'Lars Kiesow', 'manual'),
+ ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', u'Lars Kiesow',
+ 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@@ -210,28 +214,28 @@
#latex_domain_indices = True
-# -- Options for manual page output --------------------------------------------
+# -- Options for manual page output -------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
- ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation',
- [u'Lars Kiesow'], 1)
+ ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation',
+ [u'Lars Kiesow'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
-# -- Options for Texinfo output ------------------------------------------------
+# -- Options for Texinfo output -----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
- ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation',
- u'Lars Kiesow', 'Lernfunk3', 'One line description of project.',
- 'Miscellaneous'),
+ ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation',
+ u'Lars Kiesow', 'Lernfunk3', 'One line description of project.',
+ 'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
@@ -248,36 +252,18 @@
intersphinx_mapping = {'http://docs.python.org/': None}
-# Ugly way of setting tabsize
-import re
-
-def process_docstring(app, what, name, obj, options, lines):
- '''
- spaces_pat = re.compile(r"(?<= )( {8})")
- ll = []
- for l in lines:
- ll.append(spaces_pat.sub(" ",l))
- '''
- spaces_pat = re.compile(r"^ *")
- ll = []
- for l in lines:
- spacelen = len(spaces_pat.search(l).group(0))
- newlen = (spacelen % 8) + (spacelen / 8 * 4)
- ll.append( (' '*newlen) + l.lstrip(' ') )
- lines[:] = ll
-
-
# Include the GitHub readme file in index.rst
r = re.compile(r'\[`*([^\]`]+)`*\]\(([^\)]+)\)')
r2 = re.compile(r'.. include-github-readme')
+
+
def substitute_link(app, docname, text):
- if docname == 'index':
- readme_text = ''
- with codecs.open(os.path.abspath('../readme.md'), 'r', 'utf-8') as f:
- readme_text = r.sub(r'`\1 <\2>`_', f.read())
- text[0] = r2.sub(readme_text, text[0])
+ if docname == 'index':
+ readme_text = ''
+ with codecs.open(os.path.abspath('../readme.rst'), 'r', 'utf-8') as f:
+ readme_text = r.sub(r'`\1 <\2>`_', f.read())
+ text[0] = r2.sub(readme_text, text[0])
def setup(app):
- app.connect('autodoc-process-docstring', process_docstring)
- app.connect('source-read', substitute_link)
+ app.connect('source-read', substitute_link)
diff --git a/doc/ext/api.ext.base.rst b/doc/ext/api.ext.base.rst
index 0d6573c..047f752 100644
--- a/doc/ext/api.ext.base.rst
+++ b/doc/ext/api.ext.base.rst
@@ -1,5 +1,6 @@
.. raw:: html
+
Contents
diff --git a/doc/ext/api.ext.dc.rst b/doc/ext/api.ext.dc.rst
new file mode 100644
index 0000000..1700156
--- /dev/null
+++ b/doc/ext/api.ext.dc.rst
@@ -0,0 +1,8 @@
+.. raw:: html
+
+
+ Contents
+
+
+.. automodule:: feedgen.ext.dc
+ :members:
diff --git a/doc/ext/api.ext.podcast.rst b/doc/ext/api.ext.podcast.rst
index 0fdcb0c..8aaf6d6 100644
--- a/doc/ext/api.ext.podcast.rst
+++ b/doc/ext/api.ext.podcast.rst
@@ -1,5 +1,6 @@
.. raw:: html
+
Contents
diff --git a/doc/ext/api.ext.podcast_entry.rst b/doc/ext/api.ext.podcast_entry.rst
index 2ee1e0f..e713111 100644
--- a/doc/ext/api.ext.podcast_entry.rst
+++ b/doc/ext/api.ext.podcast_entry.rst
@@ -1,5 +1,6 @@
.. raw:: html
+
Contents
diff --git a/doc/ext/api.ext.torrent.rst b/doc/ext/api.ext.torrent.rst
new file mode 100644
index 0000000..ccfbc7b
--- /dev/null
+++ b/doc/ext/api.ext.torrent.rst
@@ -0,0 +1,8 @@
+.. raw:: html
+
+
+ Contents
+
+
+.. automodule:: feedgen.ext.torrent
+ :members:
diff --git a/feedgen/__init__.py b/feedgen/__init__.py
index aef2c72..b28c0e9 100644
--- a/feedgen/__init__.py
+++ b/feedgen/__init__.py
@@ -1,136 +1,135 @@
# -*- coding: utf-8 -*-
"""
- =======
- feedgen
- =======
+ =======
+ feedgen
+ =======
- This module can be used to generate web feeds in both ATOM and RSS format.
- It has support for extensions. Included is for example an extension to
- produce Podcasts.
+ This module can be used to generate web feeds in both ATOM and RSS format.
+ It has support for extensions. Included is for example an extension to
+ produce Podcasts.
- :copyright: 2013 by Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :copyright: 2013 by Lars Kiesow
+ :license: FreeBSD and LGPL, see license.* for more details.
- -------------
- Create a Feed
- -------------
+ -------------
+ Create a Feed
+ -------------
- To create a feed simply instanciate the FeedGenerator class and insert some
- data::
+ To create a feed simply instantiate the FeedGenerator class and insert some
+ data::
- >>> from feedgen.feed import FeedGenerator
- >>> fg = FeedGenerator()
- >>> fg.id('http://lernfunk.de/media/654321')
- >>> fg.title('Some Testfeed')
- >>> fg.author( {'name':'John Doe','email':'john@example.de'} )
- >>> fg.link( href='http://example.com', rel='alternate' )
- >>> fg.logo('http://ex.com/logo.jpg')
- >>> fg.subtitle('This is a cool feed!')
- >>> fg.link( href='http://larskiesow.de/test.atom', rel='self' )
- >>> fg.language('en')
+ >>> from feedgen.feed import FeedGenerator
+ >>> fg = FeedGenerator()
+ >>> fg.id('http://lernfunk.de/media/654321')
+ >>> fg.title('Some Testfeed')
+ >>> fg.author( {'name':'John Doe','email':'john@example.de'} )
+ >>> fg.link( href='http://example.com', rel='alternate' )
+ >>> fg.logo('http://ex.com/logo.jpg')
+ >>> fg.subtitle('This is a cool feed!')
+ >>> fg.link( href='http://larskiesow.de/test.atom', rel='self' )
+ >>> fg.language('en')
- Note that for the methods which set fields that can occur more than once in
- a feed you can use all of the following ways to provide data:
+ Note that for the methods which set fields that can occur more than once in
+ a feed you can use all of the following ways to provide data:
- - Provide the data for that element as keyword arguments
- - Provide the data for that element as dictionary
- - Provide a list of dictionaries with the data for several elements
+ - Provide the data for that element as keyword arguments
+ - Provide the data for that element as dictionary
+ - Provide a list of dictionaries with the data for several elements
- Example::
+ Example::
- >>> fg.contributor( name='John Doe', email='jdoe@example.com' )
- >>> fg.contributor({'name':'John Doe', 'email':'jdoe@example.com'})
- >>> fg.contributor([{'name':'John Doe', 'email':'jdoe@example.com'}, ...])
+ >>> fg.contributor(name='John Doe', email='jdoe@example.com' )
+ >>> fg.contributor({'name':'John Doe', 'email':'jdoe@example.com'})
+ >>> fg.contributor([{'name':'John', 'email':'jdoe@example.com'}, …])
- -----------------
- Generate the Feed
- -----------------
+ -----------------
+ Generate the Feed
+ -----------------
- After that you can generate both RSS or ATOM by calling the respective method::
+ After that you can generate both RSS or ATOM by calling the respective
+ method::
- >>> atomfeed = fg.atom_str(pretty=True) # Get the ATOM feed as string
- >>> rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string
- >>> fg.atom_file('atom.xml') # Write the ATOM feed to a file
- >>> fg.rss_file('rss.xml') # Write the RSS feed to a file
+ >>> atomfeed = fg.atom_str(pretty=True) # Get the ATOM feed as string
+ >>> rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string
+ >>> fg.atom_file('atom.xml') # Write the ATOM feed to a file
+ >>> fg.rss_file('rss.xml') # Write the RSS feed to a file
- ----------------
- Add Feed Entries
- ----------------
+ ----------------
+ Add Feed Entries
+ ----------------
- To add entries (items) to a feed you need to create new FeedEntry objects
- and append them to the list of entries in the FeedGenerator. The most
- convenient way to go is to use the FeedGenerator itself for the
- instantiation of the FeedEntry object::
+ To add entries (items) to a feed you need to create new FeedEntry objects
+ and append them to the list of entries in the FeedGenerator. The most
+ convenient way to do this, is to call the method add_entry(...) like this::
- >>> fe = fg.add_entry()
- >>> fe.id('http://lernfunk.de/media/654321/1')
- >>> fe.title('The First Episode')
+ >>> fe = fg.add_entry()
+ >>> fe.id('http://lernfunk.de/media/654321/1')
+ >>> fe.title('The First Episode')
- The FeedGenerators method add_entry(...) without argument provides will
- automatically generate a new FeedEntry object, append it to the feeds
- internal list of entries and return it, so that additional data can be
- added.
+ The FeedGenerator's method add_entry(...) will automatically create a new
+ FeedEntry object, append it to the feeds internal list of entries and
+ return it, so that additional data can be added.
- ----------
- Extensions
- ----------
+ ----------
+ Extensions
+ ----------
- The FeedGenerator supports extension to include additional data into the XML
- structure of the feeds. Extensions can be loaded like this::
+ The FeedGenerator supports extension to include additional data into the
+ XML structure of the feeds. Extensions can be loaded like this::
- >>> fg.load_extension('someext', atom=True, rss=True)
+ >>> fg.load_extension('someext', atom=True, rss=True)
- This will try to load the extension “someext” from the file
- `ext/someext.py`. It is required that `someext.py` contains a class named
- “SomextExtension” which is required to have at least the two methods
- `extend_rss(...)` and `extend_atom(...)`. Although not required, it is
- strongly suggested to use BaseExtension from `ext/base.py` as superclass.
+ This will try to load the extension “someext” from the file
+ `ext/someext.py`. It is required that `someext.py` contains a class named
+ “SomextExtension” which is required to have at least the two methods
+ `extend_rss(...)` and `extend_atom(...)`. Although not required, it is
+ strongly suggested to use BaseExtension from `ext/base.py` as superclass.
- `load_extension('someext', ...)` will also try to load a class named
- “SomextEntryExtension” for every entry of the feed. This class can be
- located either in the same file as SomextExtension or in
- `ext/someext_entry.py` which is suggested especially for large extensions.
+ `load_extension('someext', ...)` will also try to load a class named
+ “SomextEntryExtension” for every entry of the feed. This class can be
+ located either in the same file as SomextExtension or in
+ `ext/someext_entry.py` which is suggested especially for large extensions.
- The parameters `atom` and `rss` tell the FeedGenerator if the extensions
- should only be used for either ATOM or RSS feeds. The default value for both
- parameters is true which means that the extension would be used for both
- kinds of feeds.
+ The parameters `atom` and `rss` tell the FeedGenerator if the extensions
+ should only be used for either ATOM or RSS feeds. The default value for
+ both parameters is true which means that the extension would be used for
+ both kinds of feeds.
- **Example: Produceing a Podcast**
+ **Example: Producing a Podcast**
- One extension already provided is the podcast extension. A podcast is an RSS
- feed with some additional elements for ITunes.
+ One extension already provided is the podcast extension. A podcast is an
+ RSS feed with some additional elements for ITunes.
- To produce a podcast simply load the `podcast` extension::
+ To produce a podcast simply load the `podcast` extension::
- >>> from feedgen.feed import FeedGenerator
- >>> fg = FeedGenerator()
- >>> fg.load_extension('podcast')
- ...
- >>> fg.podcast.itunes_category('Technology', 'Podcasting')
- ...
- >>> fg.rss_str(pretty=True)
- >>> fg.rss_file('podcast.xml')
+ >>> from feedgen.feed import FeedGenerator
+ >>> fg = FeedGenerator()
+ >>> fg.load_extension('podcast')
+ ...
+ >>> fg.podcast.itunes_category('Technology', 'Podcasting')
+ ...
+ >>> fg.rss_str(pretty=True)
+ >>> fg.rss_file('podcast.xml')
- Of cause the extension has to be loaded for the FeedEntry objects as well
- but this is done automatically by the FeedGenerator for every feed entry if
- the extension is loaded for the whole feed. You can, however, load an
- extension for a specific FeedEntry by calling `load_extension(...)` on that
- entry. But this is a rather uncommon use.
-
- Of cause you can still produce a normal ATOM or RSS feed, even if you have
- loaded some plugins by temporary disabling them during the feed generation.
- This can be done by calling the generating method with the keyword argument
- `extensions` set to `False`.
+ Of cause the extension has to be loaded for the FeedEntry objects as well
+ but this is done automatically by the FeedGenerator for every feed entry if
+ the extension is loaded for the whole feed. You can, however, load an
+ extension for a specific FeedEntry by calling `load_extension(...)` on that
+ entry. But this is a rather uncommon use.
- ---------------------
- Testing the Generator
- ---------------------
+ Of cause you can still produce a normal ATOM or RSS feed, even if you have
+ loaded some plugins by temporary disabling them during the feed generation.
+ This can be done by calling the generating method with the keyword argument
+ `extensions` set to `False`.
- You can test the module by simply executing::
+ ---------------------
+ Testing the Generator
+ ---------------------
- $ python -m feedgen
+ You can test the module by simply executing::
+
+ $ python -m feedgen
"""
diff --git a/feedgen/__main__.py b/feedgen/__main__.py
index 833a897..d7dd1aa 100644
--- a/feedgen/__main__.py
+++ b/feedgen/__main__.py
@@ -1,102 +1,150 @@
# -*- coding: utf-8 -*-
'''
- feedgen
- ~~~~~~~
+ feedgen
+ ~~~~~~~
- :copyright: 2013, Lars Kiesow
+ :copyright: 2013-2016, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
-from feedgen.feed import FeedGenerator
import sys
+from feedgen.feed import FeedGenerator
+
+
+USAGE = '''
+Usage: python -m feedgen [OPTION]
+
+Use one of the following options:
+
+File options:
+ .atom -- Generate ATOM test feed
+ .rss -- Generate RSS test teed
+
+Stdout options:
+ atom -- Generate ATOM test output
+ rss -- Generate RSS test output
+ podcast -- Generate Podcast test output
+ dc.atom -- Generate DC extension test output (atom format)
+ dc.rss -- Generate DC extension test output (rss format)
+ syndication.atom -- Generate syndication extension test output (atom format)
+ syndication.rss -- Generate syndication extension test output (rss format)
+ torrent -- Generate Torrent test output
+
+'''
+
+
def print_enc(s):
- '''Print function compatible with both python2 and python3 accepting strings
- and byte arrays.
- '''
- print(s.decode('utf-8') if type(s) == type(b'') else s)
+ '''Print function compatible with both python2 and python3 accepting
+ strings and byte arrays.
+ '''
+ if sys.version_info[0] >= 3:
+ print(s.decode('utf-8') if isinstance(s, bytes) else s)
+ else:
+ print(s)
+
+
+def main():
+ if len(sys.argv) != 2 or not (
+ sys.argv[1].endswith('rss') or
+ sys.argv[1].endswith('atom') or
+ sys.argv[1] == 'torrent' or
+ sys.argv[1] == 'podcast'):
+ print(USAGE)
+ exit()
+
+ arg = sys.argv[1]
+
+ fg = FeedGenerator()
+ fg.id('http://lernfunk.de/_MEDIAID_123')
+ fg.title('Testfeed')
+ fg.author({'name': 'Lars Kiesow', 'email': 'lkiesow@uos.de'})
+ fg.link(href='http://example.com', rel='alternate')
+ fg.category(term='test')
+ fg.contributor(name='Lars Kiesow', email='lkiesow@uos.de')
+ fg.contributor(name='John Doe', email='jdoe@example.com')
+ fg.icon('http://ex.com/icon.jpg')
+ fg.logo('http://ex.com/logo.jpg')
+ fg.rights('cc-by')
+ fg.subtitle('This is a cool feed!')
+ fg.link(href='http://larskiesow.de/test.atom', rel='self')
+ fg.language('de')
+ fe = fg.add_entry()
+ fe.id('http://lernfunk.de/_MEDIAID_123#1')
+ fe.title('First Element')
+ fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Tamen aberramus a proposito, et, ne longius, prorsus, inquam, Piso,
+ si ista mala sunt, placet. Aut etiam, ut vestitum, sic sententiam
+ habeas aliam domesticam, aliam forensem, ut in fronte ostentatio
+ sit, intus veritas occultetur? Cum id fugiunt, re eadem defendunt,
+ quae Peripatetici, verba.''')
+ fe.summary(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…')
+ fe.link(href='http://example.com', rel='alternate')
+ fe.author(name='Lars Kiesow', email='lkiesow@uos.de')
+
+ if arg == 'atom':
+ print_enc(fg.atom_str(pretty=True))
+ elif arg == 'rss':
+ print_enc(fg.rss_str(pretty=True))
+ elif arg == 'podcast':
+ # Load the podcast extension. It will automatically be loaded for all
+ # entries in the feed, too. Thus also for our “fe”.
+ fg.load_extension('podcast')
+ fg.podcast.itunes_author('Lars Kiesow')
+ fg.podcast.itunes_category('Technology', 'Podcasting')
+ fg.podcast.itunes_explicit('no')
+ fg.podcast.itunes_complete('no')
+ fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss')
+ fg.podcast.itunes_owner('John Doe', 'john@example.com')
+ fg.podcast.itunes_summary('Lorem ipsum dolor sit amet, consectetur ' +
+ 'adipiscing elit. Verba tu fingas et ea ' +
+ 'dicas, quae non sentias?')
+ fg.podcast.itunes_type('episodic')
+ fe.podcast.itunes_author('Lars Kiesow')
+ fe.podcast.itunes_season(1)
+ fe.podcast.itunes_episode(1)
+ fe.podcast.itunes_title('First podcast episode')
+ fe.podcast.itunes_episode_type('full')
+ print_enc(fg.rss_str(pretty=True))
+
+ elif arg == 'torrent':
+ fg.load_extension('torrent')
+ fe.link(href='http://example.com/torrent/debian-8-netint.iso.torrent',
+ rel='alternate',
+ type='application/x-bittorrent, length=1000')
+ fe.torrent.filename('debian-8.4.0-i386-netint.iso.torrent')
+ fe.torrent.infohash('7661229811ef32014879ceedcdf4a48f256c88ba')
+ fe.torrent.contentlength('331350016')
+ fe.torrent.seeds('789')
+ fe.torrent.peers('456')
+ fe.torrent.verified('123')
+ print_enc(fg.rss_str(pretty=True))
+
+ elif arg.startswith('dc.'):
+ fg.load_extension('dc')
+ fg.dc.dc_contributor('Lars Kiesow')
+ if arg.endswith('.atom'):
+ print_enc(fg.atom_str(pretty=True))
+ else:
+ print_enc(fg.rss_str(pretty=True))
+
+ elif arg.startswith('syndication'):
+ fg.load_extension('syndication')
+ fg.syndication.update_period('daily')
+ fg.syndication.update_frequency(2)
+ fg.syndication.update_base('2000-01-01T12:00+00:00')
+ if arg.endswith('.rss'):
+ print_enc(fg.rss_str(pretty=True))
+ else:
+ print_enc(fg.atom_str(pretty=True))
+
+ elif arg.endswith('atom'):
+ fg.atom_file(arg)
+ elif arg.endswith('rss'):
+ fg.rss_file(arg)
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 -- Generator Podcast test output and print it to stdout.')
- print_enc ('')
- exit()
-
- arg = sys.argv[1]
-
- fg = FeedGenerator()
- fg.id('http://lernfunk.de/_MEDIAID_123')
- fg.title('Testfeed')
- fg.author( {'name':'Lars Kiesow','email':'lkiesow@uos.de'} )
- fg.link( href='http://example.com', rel='alternate' )
- fg.category(term='test')
- fg.contributor( name='Lars Kiesow', email='lkiesow@uos.de' )
- fg.contributor( name='John Doe', email='jdoe@example.com' )
- fg.icon('http://ex.com/icon.jpg')
- fg.logo('http://ex.com/logo.jpg')
- fg.rights('cc-by')
- fg.subtitle('This is a cool feed!')
- fg.link( href='http://larskiesow.de/test.atom', rel='self' )
- fg.language('de')
- fe = fg.add_entry()
- fe.id('http://lernfunk.de/_MEDIAID_123#1')
- fe.title('First Element')
- fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen
- aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si ista
- mala sunt, placet. Aut etiam, ut vestitum, sic sententiam habeas aliam
- domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas
- occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici,
- verba.''')
- fe.summary('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 == 'dc.atom':
- fg.load_extension('dc')
- fg.dc.dc_contributor('Lars Kiesow')
- fe.dc.dc_contributor('Lars Kiesow')
- print_enc (fg.atom_str(pretty=True))
-
- elif arg == 'dc.rss':
- fg.load_extension('dc')
- fg.dc.dc_contributor('Lars Kiesow')
- print_enc (fg.rss_str(pretty=True))
-
- elif arg.endswith('atom'):
- fg.atom_file(arg)
-
- elif arg.endswith('rss'):
- fg.rss_file(arg)
+ main()
diff --git a/feedgen/compat.py b/feedgen/compat.py
new file mode 100644
index 0000000..e9044b0
--- /dev/null
+++ b/feedgen/compat.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+
+import sys
+
+if sys.version_info[0] >= 3:
+ string_types = str
+else:
+ string_types = basestring # noqa: F821
diff --git a/feedgen/entry.py b/feedgen/entry.py
index 499c387..5dd21c2 100644
--- a/feedgen/entry.py
+++ b/feedgen/entry.py
@@ -1,651 +1,738 @@
# -*- coding: utf-8 -*-
'''
- feedgen.entry
- ~~~~~~~~~~~~~
+ feedgen.entry
+ ~~~~~~~~~~~~~
- :copyright: 2013, Lars Kiesow
+ :copyright: 2013-2020, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
-from lxml import etree
from datetime import datetime
+
import dateutil.parser
import dateutil.tz
-from feedgen.util import ensure_format
+import warnings
+
+from lxml.etree import CDATA # nosec - adding CDATA entry is safe
+
+from feedgen.compat import string_types
+from feedgen.util import ensure_format, formatRFC2822, xml_fromstring, xml_elem
+
+
+def _add_text_elm(entry, data, name):
+ """Add a text subelement to an entry"""
+ if not data:
+ return
+
+ elm = xml_elem(name, entry)
+ type_ = data.get('type')
+ if data.get('src'):
+ if name != 'content':
+ raise ValueError("Only the 'content' element of an entry can "
+ "contain a 'src' attribute")
+ elm.attrib['src'] = data['src']
+ elif data.get(name):
+ # Surround xhtml with a div tag, parse it and embed it
+ if type_ == 'xhtml':
+ xhtml = '' \
+ + data.get(name) + '
'
+ elm.append(xml_fromstring(xhtml))
+ elif type_ == 'CDATA':
+ elm.text = CDATA(data.get(name))
+ # Parse XML and embed it
+ elif type_ and (type_.endswith('/xml') or type_.endswith('+xml')):
+ elm.append(xml_fromstring(data[name]))
+ # Embed the text in escaped form
+ elif not type_ or type_.startswith('text') or type_ == 'html':
+ elm.text = data.get(name)
+ # Everything else should be included base64 encoded
+ else:
+ raise NotImplementedError(
+ 'base64 encoded {} is not supported at the moment. '
+ 'Pull requests adding support are welcome.'.format(name)
+ )
+ # Add type description of the content
+ if type_:
+ elm.attrib['type'] = type_
class FeedEntry(object):
- '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item
- node.
- '''
-
- 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')))
- # 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(feed, 'category', term=c['term'])
- if c.get('schema'):
- cat.attrib['schema'] = c['schema']
- 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 = self.__rss_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
- 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 = self.__rss_pubDate.strftime(
- '%a, %d %b %Y %H:%M:%S %z')
-
- 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, basestring):
- 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.
- :returns: Content element of the entry.
- '''
- if not src is None:
- self.__atom_content = {'src':src}
- elif not content is None:
- self.__atom_content = {'content':content}
- if not type is None:
- self.__atom_content['type'] = type
- self.__rss_content = content
- 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:schema is the
- # rss:domain.
- self.__rss_category = []
- for cat in self.__atom_category:
- rss_cat = {}
- rss_cat['value'] = cat['label'] if cat.get('label') else cat['term']
- if cat.get('schema'):
- rss_cat['domain'] = cat['schema']
- 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, basestring):
- published = dateutil.parser.parse(published)
- if not isinstance(published, datetime):
- raise ValueError('Invalid datetime format')
- if published.tzinfo is None:
- raise ValueError('Datetime object has no timezone info')
- self.__atom_published = published
- self.__rss_pubDate = published
-
- return self.__atom_published
-
-
- def pubdate(self, pubDate=None):
- '''Get or set the pubDate of the entry which indicates when the entry was
- published. This method is just another name for the published(...)
- method.
- '''
- return self.published(pubDate)
-
-
- def rights(self, rights=None):
- '''Get or set the rights value of the entry which conveys information
- about rights, e.g. copyrights, held in and over the entry. This ATOM value
- will also set rss:copyright.
-
- :param rights: Rights information of the feed.
- :returns: Rights information of the feed.
- '''
- if not rights is None:
- self.__atom_rights = rights
- return self.__atom_rights
-
-
- def comments(self, comments=None):
- '''Get or set the the value of comments which is the url of the comments
- page for the item. This is a RSS only value.
-
- :param comments: URL to the comments page.
- :returns: URL to the comments page.
- '''
- if not comments is None:
- self.__rss_comments = comments
- return self.__rss_comments
-
-
- def enclosure(self, url=None, length=None, type=None):
- '''Get or set the value of enclosure which describes a media object that
- is attached to the item. This is a RSS only value which is represented by
- link(rel=enclosure) in ATOM. ATOM feeds can furthermore contain several
- enclosures while RSS may contain only one. That is why this method, if
- repeatedly called, will add more than one enclosures to the feed.
- However, only the last one is used for RSS.
-
- :param url: URL of the media object.
- :param length: Size of the media in bytes.
- :param type: Mimetype of the linked media.
- :returns: Data of the enclosure element.
- '''
- if not url is None:
- self.link( href=url, rel='enclosure', type=type, length=length )
- return self.__rss_enclosure
-
-
- def ttl(self, ttl=None):
- '''Get or set the ttl value. It is an RSS only element. ttl stands for
- time to live. It's a number of minutes that indicates how long a channel
- can be cached before refreshing from the source.
-
- :param ttl: Integer value representing the time to live.
- :returns: Time to live of of the entry.
- '''
- if not ttl is None:
- self.__rss_ttl = int(ttl)
- return self.__rss_ttl
-
-
- def load_extension(self, name, atom=True, rss=True):
- '''Load a specific extension by name.
-
- :param name: Name of the extension to load.
- :param atom: If the extension should be used for ATOM feeds.
- :param rss: If the extension should be used for RSS feeds.
- '''
- # Check loaded extensions
- if not isinstance(self.__extensions, dict):
- self.__extensions = {}
- if name in self.__extensions.keys():
- raise ImportError('Extension already loaded')
-
- # Load extension
- extname = name[0].upper() + name[1:] + 'EntryExtension'
-
- # Try to import extension from dedicated module for entry:
- try:
- supmod = __import__('feedgen.ext.%s_entry' % name)
- extmod = getattr(supmod.ext, name + '_entry')
- except ImportError:
- # Try the FeedExtension module instead
- supmod = __import__('feedgen.ext.%s' % name)
- extmod = getattr(supmod.ext, name)
-
- ext = getattr(extmod, extname)
- extinst = ext()
- setattr(self, name, extinst)
- self.__extensions[name] = {'inst':extinst,'atom':atom,'rss':rss}
+ '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds
+ item node.
+ '''
+
+ def __init__(self):
+ # ATOM
+ # required
+ self.__atom_id = None
+ self.__atom_title = None
+ self.__atom_updated = datetime.now(dateutil.tz.tzutc())
+
+ # recommended
+ self.__atom_author = None
+ self.__atom_content = None
+ self.__atom_link = None
+ self.__atom_summary = None
+
+ # optional
+ self.__atom_category = None
+ self.__atom_contributor = None
+ self.__atom_published = None
+ self.__atom_source = None
+ self.__atom_rights = None
+
+ # RSS
+ self.__rss_author = None
+ self.__rss_category = None
+ self.__rss_comments = None
+ self.__rss_description = None
+ self.__rss_content = None
+ self.__rss_enclosure = None
+ self.__rss_guid = {}
+ self.__rss_link = None
+ self.__rss_pubDate = None
+ self.__rss_source = None
+ self.__rss_title = None
+
+ # Extension list:
+ self.__extensions = {}
+ self.__extensions_register = {}
+
+ def atom_entry(self, extensions=True):
+ '''Create an ATOM entry and return it.'''
+ entry = xml_elem('entry')
+ if not (self.__atom_id and self.__atom_title and self.__atom_updated):
+ raise ValueError('Required fields not set')
+ id = xml_elem('id', entry)
+ id.text = self.__atom_id
+ title = xml_elem('title', entry)
+ title.text = self.__atom_title
+ updated = xml_elem('updated', entry)
+ updated.text = self.__atom_updated.isoformat()
+
+ # An entry must contain an alternate link if there is no content
+ # element.
+ if not self.__atom_content:
+ links = self.__atom_link or []
+ if not [link for link in links if link.get('rel') == 'alternate']:
+ raise ValueError('Entry must contain an alternate link or '
+ 'a content element.')
+
+ # Add author elements
+ for a in self.__atom_author or []:
+ # Atom requires a name. Skip elements without.
+ if not a.get('name'):
+ continue
+ author = xml_elem('author', entry)
+ name = xml_elem('name', author)
+ name.text = a.get('name')
+ if a.get('email'):
+ email = xml_elem('email', author)
+ email.text = a.get('email')
+ if a.get('uri'):
+ uri = xml_elem('uri', author)
+ uri.text = a.get('uri')
+
+ _add_text_elm(entry, self.__atom_content, 'content')
+
+ for link in self.__atom_link or []:
+ link = xml_elem('link', entry, href=link['href'])
+ if link.get('rel'):
+ link.attrib['rel'] = link['rel']
+ if link.get('type'):
+ link.attrib['type'] = link['type']
+ if link.get('hreflang'):
+ link.attrib['hreflang'] = link['hreflang']
+ if link.get('title'):
+ link.attrib['title'] = link['title']
+ if link.get('length'):
+ link.attrib['length'] = link['length']
+
+ _add_text_elm(entry, self.__atom_summary, 'summary')
+
+ for c in self.__atom_category or []:
+ cat = xml_elem('category', entry, 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 = xml_elem('contributor', entry)
+ name = xml_elem('name', contrib)
+ name.text = c.get('name')
+ if c.get('email'):
+ email = xml_elem('email', contrib)
+ email.text = c.get('email')
+ if c.get('uri'):
+ uri = xml_elem('uri', contrib)
+ uri.text = c.get('uri')
+
+ if self.__atom_published:
+ published = xml_elem('published', entry)
+ published.text = self.__atom_published.isoformat()
+
+ if self.__atom_rights:
+ rights = xml_elem('rights', entry)
+ rights.text = self.__atom_rights
+
+ if self.__atom_source:
+ source = xml_elem('source', entry)
+ if self.__atom_source.get('title'):
+ source_title = xml_elem('title', source)
+ source_title.text = self.__atom_source['title']
+ if self.__atom_source.get('link'):
+ xml_elem('link', source, href=self.__atom_source['link'])
+
+ 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 = xml_elem('item')
+ if not (self.__rss_title or
+ self.__rss_description or
+ self.__rss_content):
+ raise ValueError('Required fields not set')
+ if self.__rss_title:
+ title = xml_elem('title', entry)
+ title.text = self.__rss_title
+ if self.__rss_link:
+ link = xml_elem('link', entry)
+ link.text = self.__rss_link
+ if self.__rss_description and self.__rss_content:
+ description = xml_elem('description', entry)
+ description.text = self.__rss_description
+ XMLNS_CONTENT = 'http://purl.org/rss/1.0/modules/content/'
+ content = xml_elem('{%s}encoded' % XMLNS_CONTENT, entry)
+ content.text = CDATA(self.__rss_content['content']) \
+ if self.__rss_content.get('type', '') == 'CDATA' \
+ else self.__rss_content['content']
+ elif self.__rss_description:
+ description = xml_elem('description', entry)
+ description.text = self.__rss_description
+ elif self.__rss_content:
+ description = xml_elem('description', entry)
+ description.text = CDATA(self.__rss_content['content']) \
+ if self.__rss_content.get('type', '') == 'CDATA' \
+ else self.__rss_content['content']
+ for a in self.__rss_author or []:
+ author = xml_elem('author', entry)
+ author.text = a
+ if self.__rss_guid.get('guid'):
+ guid = xml_elem('guid', entry)
+ guid.text = self.__rss_guid['guid']
+ permaLink = str(self.__rss_guid.get('permalink', False)).lower()
+ guid.attrib['isPermaLink'] = permaLink
+ for cat in self.__rss_category or []:
+ category = xml_elem('category', entry)
+ category.text = cat['value']
+ if cat.get('domain'):
+ category.attrib['domain'] = cat['domain']
+ if self.__rss_comments:
+ comments = xml_elem('comments', entry)
+ comments.text = self.__rss_comments
+ if self.__rss_enclosure:
+ enclosure = xml_elem('enclosure', entry)
+ enclosure.attrib['url'] = self.__rss_enclosure['url']
+ enclosure.attrib['length'] = self.__rss_enclosure['length']
+ enclosure.attrib['type'] = self.__rss_enclosure['type']
+ if self.__rss_pubDate:
+ pubDate = xml_elem('pubDate', entry)
+ pubDate.text = formatRFC2822(self.__rss_pubDate)
+ if self.__rss_source:
+ source = xml_elem('source', entry, url=self.__rss_source['url'])
+ source.text = self.__rss_source['title']
+
+ if extensions:
+ for ext in self.__extensions.values() or []:
+ if ext.get('rss'):
+ ext['inst'].extend_rss(entry)
+
+ return entry
+
+ def title(self, title=None):
+ '''Get or set the title value of the entry. It should contain a human
+ readable title for the entry. Title is mandatory for both ATOM and RSS
+ and should not be blank.
+
+ :param title: The new title of the entry.
+ :returns: The entriess title.
+ '''
+ if title is not None:
+ self.__atom_title = title
+ self.__rss_title = title
+ return self.__atom_title
+
+ def id(self, id=None):
+ '''Get or set the entry id which identifies the entry using a
+ universally unique and permanent URI. Two entries in a feed can have
+ the same value for id if they represent the same entry at different
+ points in time. This method will also set rss:guid with permalink set
+ to False. Id is mandatory for an ATOM entry.
+
+ :param id: New Id of the entry.
+ :returns: Id of the entry.
+ '''
+ if id is not None:
+ self.__atom_id = id
+ self.__rss_guid = {'guid': id, 'permalink': False}
+ return self.__atom_id
+
+ def guid(self, guid=None, permalink=False):
+ '''Get or set the entries guid which is a string that uniquely
+ identifies the item. This will also set atom:id.
+
+ :param guid: Id of the entry.
+ :param permalink: If this is a permanent identifier for this item
+ :returns: Id and permalink setting of the entry.
+ '''
+ if guid is not None:
+ self.__atom_id = guid
+ self.__rss_guid = {'guid': guid, 'permalink': permalink}
+ return self.__rss_guid
+
+ def updated(self, updated=None):
+ '''Set or get the updated value which indicates the last time the entry
+ was modified in a significant way.
+
+ The value can either be a string which will automatically be parsed or
+ a datetime.datetime object. In any case it is necessary that the value
+ include timezone information.
+
+ :param updated: The modification date.
+ :returns: Modification date as datetime.datetime
+ '''
+ if updated is not None:
+ if isinstance(updated, string_types):
+ updated = dateutil.parser.parse(updated)
+ if not isinstance(updated, datetime):
+ raise ValueError('Invalid datetime format')
+ if updated.tzinfo is None:
+ raise ValueError('Datetime object has no timezone info')
+ self.__atom_updated = updated
+ self.__rss_lastBuildDate = updated
+
+ return self.__atom_updated
+
+ def author(self, author=None, replace=False, **kwargs):
+ '''Get or set author data. An author element is a dict containing a
+ name, an email address and a uri. Name is mandatory for ATOM, email is
+ mandatory for RSS.
+
+ This method can be called with:
+ - the fields of an author as keyword arguments
+ - the fields of an author as a dictionary
+ - a list of dictionaries containing the author fields
+
+ An author has the following fields:
+ - *name* conveys a human-readable name for the person.
+ - *uri* contains a home page for the person.
+ - *email* contains an email address for the person.
+
+ :param author: Dict or list of dicts with author data.
+ :param replace: Add or replace old data.
+
+ Example::
+
+ >>> author({'name':'John Doe', 'email':'jdoe@example.com'})
+ [{'name':'John Doe','email':'jdoe@example.com'}]
+
+ >>> author([{'name': 'Mr. X'}, {'name': 'Max'}])
+ [{'name':'John Doe','email':'jdoe@example.com'},
+ {'name':'John Doe'}, {'name':'Max'}]
+
+ >>> author(name='John Doe', email='jdoe@example.com', replace=True)
+ [{'name':'John Doe','email':'jdoe@example.com'}]
+
+ '''
+ if author is None and kwargs:
+ author = kwargs
+ if author is not None:
+ if replace or self.__atom_author is None:
+ self.__atom_author = []
+ self.__atom_author += ensure_format(author,
+ set(['name', 'email', 'uri']),
+ set())
+ self.__rss_author = []
+ for a in self.__atom_author:
+ if a.get('email'):
+ if a.get('name'):
+ self.__rss_author.append('%(email)s (%(name)s)' % a)
+ else:
+ self.__rss_author.append('%(email)s' % a)
+ return self.__atom_author
+
+ def content(self, content=None, src=None, type=None):
+ '''Get or set the content of the entry which contains or links to the
+ complete content of the entry. Content must be provided for ATOM
+ entries if there is no alternate link, and should be provided if there
+ is no summary. If the content is set (not linked) it will also set
+ rss:description.
+
+ :param content: The content of the feed entry.
+ :param src: Link to the entries content.
+ :param type: If type is CDATA content would not be escaped.
+ :returns: Content element of the entry.
+ '''
+ if src is not None:
+ self.__atom_content = {'src': src}
+ elif content is not None:
+ self.__atom_content = {'content': content}
+ self.__rss_content = {'content': content}
+ if type is not None:
+ self.__atom_content['type'] = type
+ self.__rss_content['type'] = type
+ return self.__atom_content
+
+ def link(self, link=None, replace=False, **kwargs):
+ '''Get or set link data. An link element is a dict with the fields
+ href, rel, type, hreflang, title, and length. Href is mandatory for
+ ATOM.
+
+ This method can be called with:
+ - the fields of a link as keyword arguments
+ - the fields of a link as a dictionary
+ - a list of dictionaries containing the link fields
+
+ A link has the following fields:
+
+ - *href* is the URI of the referenced resource (typically a Web page)
+ - *rel* contains a single link relationship type. It can be a full URI,
+ or one of the following predefined values (default=alternate):
+
+ - *alternate* an alternate representation of the entry or feed, for
+ example a permalink to the html version of the entry, or the
+ front page of the weblog.
+ - *enclosure* a related resource which is potentially large in size
+ and might require special handling, for example an audio or video
+ recording.
+ - *related* an document related to the entry or feed.
+ - *self* the feed itself.
+ - *via* the source of the information provided in the entry.
+
+ - *type* indicates the media type of the resource.
+ - *hreflang* indicates the language of the referenced resource.
+ - *title* human readable information about the link, typically for
+ display purposes.
+ - *length* the length of the resource, in bytes.
+
+ RSS only supports one link with nothing but a URL. So for the RSS link
+ element the last link with rel=alternate is used.
+
+ RSS also supports one enclusure element per entry which is covered by
+ the link element in ATOM feed entries. So for the RSS enclusure element
+ the last link with rel=enclosure is used.
+
+ :param link: Dict or list of dicts with data.
+ :param replace: Add or replace old data.
+ :returns: List of link data.
+ '''
+ if link is None and kwargs:
+ link = kwargs
+ if link is not None:
+ if replace or self.__atom_link is None:
+ self.__atom_link = []
+ self.__atom_link += ensure_format(
+ link,
+ set(['href', 'rel', 'type', 'hreflang', 'title', 'length']),
+ set(['href']),
+ {'rel': ['alternate', 'enclosure', 'related', 'self', 'via']},
+ {'rel': 'alternate'})
+ # RSS only needs one URL. We use the first link for RSS:
+ for link in self.__atom_link:
+ if link.get('rel') == 'alternate':
+ self.__rss_link = link['href']
+ elif link.get('rel') == 'enclosure':
+ self.__rss_enclosure = {'url': link['href']}
+ self.__rss_enclosure['type'] = link.get('type')
+ self.__rss_enclosure['length'] = link.get('length') or '0'
+ # return the set with more information (atom)
+ return self.__atom_link
+
+ def summary(self, summary=None, type=None):
+ '''Get or set the summary element of an entry which conveys a short
+ summary, abstract, or excerpt of the entry. Summary is an ATOM only
+ element and should be provided if there either is no content provided
+ for the entry, or that content is not inline (i.e., contains a src
+ attribute), or if the content is encoded in base64. This method will
+ also set the rss:description field if it wasn't previously set or
+ contains the old value of summary.
+
+ :param summary: Summary of the entries contents.
+ :returns: Summary of the entries contents.
+ '''
+ if summary is not None:
+ # Replace the RSS description with the summary if it was the
+ # summary before. Not if it is the description.
+ if not self.__rss_description or (
+ self.__atom_summary and
+ self.__rss_description == self.__atom_summary.get("summary")
+ ):
+ self.__rss_description = summary
+
+ self.__atom_summary = {'summary': summary}
+ if type is not None:
+ self.__atom_summary['type'] = type
+ return self.__atom_summary
+
+ def description(self, description=None, isSummary=False):
+ '''Get or set the description value which is the item synopsis.
+ Description is an RSS only element. For ATOM feeds it is split in
+ summary and content. The isSummary parameter can be used to control
+ which ATOM value is set when setting description.
+
+ :param description: Description of the entry.
+ :param isSummary: If the description should be used as content or
+ summary.
+ :returns: The entries description.
+ '''
+ if description is not None:
+ self.__rss_description = description
+ if isSummary:
+ self.__atom_summary = {'summary': description}
+ else:
+ self.__atom_content = {'content': description}
+ return self.__rss_description
+
+ def category(self, category=None, replace=False, **kwargs):
+ '''Get or set categories that the entry belongs to.
+
+ This method can be called with:
+ - the fields of a category as keyword arguments
+ - the fields of a category as a dictionary
+ - a list of dictionaries containing the category fields
+
+ A categories has the following fields:
+ - *term* identifies the category
+ - *scheme* identifies the categorization scheme via a URI.
+ - *label* provides a human-readable label for display
+
+ If a label is present it is used for the RSS feeds. Otherwise the term
+ is used. The scheme is used for the domain attribute in RSS.
+
+ :param category: Dict or list of dicts with data.
+ :param replace: Add or replace old data.
+ :returns: List of category data.
+ '''
+ if category is None and kwargs:
+ category = kwargs
+ if category is not None:
+ if replace or self.__atom_category is None:
+ self.__atom_category = []
+ self.__atom_category += ensure_format(
+ category,
+ set(['term', 'scheme', 'label']),
+ set(['term']))
+ # Map the ATOM categories to RSS categories. Use the atom:label as
+ # name or if not present the atom:term. The atom:scheme is the
+ # rss:domain.
+ self.__rss_category = []
+ for cat in self.__atom_category:
+ rss_cat = {}
+ rss_cat['value'] = cat.get('label', cat['term'])
+ if cat.get('scheme'):
+ rss_cat['domain'] = cat['scheme']
+ self.__rss_category.append(rss_cat)
+ return self.__atom_category
+
+ def contributor(self, contributor=None, replace=False, **kwargs):
+ '''Get or set the contributor data of the feed. This is an ATOM only
+ value.
+
+ This method can be called with:
+ - the fields of an contributor as keyword arguments
+ - the fields of an contributor as a dictionary
+ - a list of dictionaries containing the contributor fields
+
+ An contributor has the following fields:
+ - *name* conveys a human-readable name for the person.
+ - *uri* contains a home page for the person.
+ - *email* contains an email address for the person.
+
+ :param contributor: Dictionary or list of dictionaries with contributor
+ data.
+ :param replace: Add or replace old data.
+ :returns: List of contributors as dictionaries.
+ '''
+ if contributor is None and kwargs:
+ contributor = kwargs
+ if contributor is not None:
+ if replace or self.__atom_contributor is None:
+ self.__atom_contributor = []
+ self.__atom_contributor += ensure_format(
+ contributor, set(['name', 'email', 'uri']), set(['name']))
+ return self.__atom_contributor
+
+ def published(self, published=None):
+ '''Set or get the published value which contains the time of the
+ initial creation or first availability of the entry.
+
+ The value can either be a string which will automatically be parsed or
+ a datetime.datetime object. In any case it is necessary that the value
+ include timezone information.
+
+ :param published: The creation date.
+ :returns: Creation date as datetime.datetime
+ '''
+ if published is not None:
+ if isinstance(published, string_types):
+ published = dateutil.parser.parse(published)
+ if not isinstance(published, datetime):
+ raise ValueError('Invalid datetime format')
+ if published.tzinfo is None:
+ raise ValueError('Datetime object has no timezone info')
+ self.__atom_published = published
+ self.__rss_pubDate = published
+
+ return self.__atom_published
+
+ def pubDate(self, pubDate=None):
+ '''Get or set the pubDate of the entry which indicates when the entry
+ was published. This method is just another name for the published(...)
+ method.
+ '''
+ return self.published(pubDate)
+
+ def pubdate(self, pubDate=None):
+ '''Get or set the pubDate of the entry which indicates when the entry
+ was published. This method is just another name for the published(...)
+ method.
+
+ pubdate(…) is deprecated and may be removed in feedgen ≥ 0.8. Use
+ pubDate(…) instead.
+ '''
+ warnings.warn('pubdate(…) is deprecated and may be removed in feedgen '
+ '≥ 0.8. Use pubDate(…) instead.')
+ return self.published(pubDate)
+
+ def rights(self, rights=None):
+ '''Get or set the rights value of the entry which conveys information
+ about rights, e.g. copyrights, held in and over the entry. This ATOM
+ value will also set rss:copyright.
+
+ :param rights: Rights information of the feed.
+ :returns: Rights information of the feed.
+ '''
+ if rights is not None:
+ self.__atom_rights = rights
+ return self.__atom_rights
+
+ def comments(self, comments=None):
+ '''Get or set the value of comments which is the URL of the comments
+ page for the item. This is a RSS only value.
+
+ :param comments: URL to the comments page.
+ :returns: URL to the comments page.
+ '''
+ if comments is not None:
+ self.__rss_comments = comments
+ return self.__rss_comments
+
+ def source(self, url=None, title=None):
+ '''Get or set the source for the current feed entry.
+
+ Note that ATOM feeds support a lot more sub elements than title and URL
+ (which is what RSS supports) but these are currently not supported.
+ Patches are welcome.
+
+ :param url: Link to the source.
+ :param title: Title of the linked resource
+ :returns: Source element as dictionaries.
+ '''
+ if url is not None and title is not None:
+ self.__rss_source = {'url': url, 'title': title}
+ self.__atom_source = {'link': url, 'title': title}
+ return self.__rss_source
+
+ def enclosure(self, url=None, length=None, type=None):
+ '''Get or set the value of enclosure which describes a media object
+ that is attached to the item. This is a RSS only value which is
+ represented by link(rel=enclosure) in ATOM. ATOM feeds can furthermore
+ contain several enclosures while RSS may contain only one. That is why
+ this method, if repeatedly called, will add more than one enclosures to
+ the feed. However, only the last one is used for RSS.
+
+ :param url: URL of the media object.
+ :param length: Size of the media in bytes.
+ :param type: Mimetype of the linked media.
+ :returns: Data of the enclosure element.
+ '''
+ if url is not None:
+ self.link(href=url, rel='enclosure', type=type, length=str(length))
+ return self.__rss_enclosure
+
+ def ttl(self, ttl=None):
+ '''Get or set the ttl value. It is an RSS only element. ttl stands for
+ time to live. It's a number of minutes that indicates how long a
+ channel can be cached before refreshing from the source.
+
+ :param ttl: Integer value representing the time to live.
+ :returns: Time to live of of the entry.
+ '''
+ if ttl is not None:
+ self.__rss_ttl = int(ttl)
+ return self.__rss_ttl
+
+ def load_extension(self, name, atom=True, rss=True):
+ '''Load a specific extension by name.
+
+ :param name: Name of the extension to load.
+ :param atom: If the extension should be used for ATOM feeds.
+ :param rss: If the extension should be used for RSS feeds.
+ '''
+ # Check loaded extensions
+ if not isinstance(self.__extensions, dict):
+ self.__extensions = {}
+ if name in self.__extensions.keys():
+ raise ImportError('Extension already loaded')
+
+ # Load extension
+ extname = name[0].upper() + name[1:] + 'EntryExtension'
+ try:
+ supmod = __import__('feedgen.ext.%s_entry' % name)
+ extmod = getattr(supmod.ext, name + '_entry')
+ except ImportError:
+ # Use FeedExtension module instead
+ supmod = __import__('feedgen.ext.%s' % name)
+ extmod = getattr(supmod.ext, name)
+ ext = getattr(extmod, extname)
+ self.register_extension(name, ext, atom, rss)
+
+ def register_extension(self, namespace, extension_class_entry=None,
+ atom=True, rss=True):
+ '''Register a specific extension by classes to a namespace.
+
+ :param namespace: namespace for the extension
+ :param extension_class_entry: Class of the entry extension to load.
+ :param atom: If the extension should be used for ATOM feeds.
+ :param rss: If the extension should be used for RSS feeds.
+ '''
+ # Check loaded extensions
+ # `load_extension` ignores the "Extension" suffix.
+ if not isinstance(self.__extensions, dict):
+ self.__extensions = {}
+ if namespace in self.__extensions.keys():
+ raise ImportError('Extension already loaded')
+ if not extension_class_entry:
+ raise ImportError('No extension class')
+
+ extinst = extension_class_entry()
+ setattr(self, namespace, extinst)
+
+ # `load_extension` registry
+ self.__extensions[namespace] = {
+ 'inst': extinst,
+ 'extension_class_entry': extension_class_entry,
+ 'atom': atom,
+ 'rss': rss
+ }
diff --git a/feedgen/ext/__init__.py b/feedgen/ext/__init__.py
index a3dd0f9..0e2b628 100644
--- a/feedgen/ext/__init__.py
+++ b/feedgen/ext/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
- ===========
- feedgen.ext
- ===========
+ ===========
+ feedgen.ext
+ ===========
"""
diff --git a/feedgen/ext/base.py b/feedgen/ext/base.py
index da7f571..b94826d 100644
--- a/feedgen/ext/base.py
+++ b/feedgen/ext/base.py
@@ -1,43 +1,44 @@
# -*- coding: utf-8 -*-
'''
- feedgen.ext.base
- ~~~~~~~~~~~~~~~~
+ feedgen.ext.base
+ ~~~~~~~~~~~~~~~~
- Basic FeedGenerator extension which does nothing but provides all necessary
- methods.
+ Basic FeedGenerator extension which does nothing but provides all necessary
+ methods.
- :copyright: 2013, Lars Kiesow
+ :copyright: 2013, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
class BaseExtension(object):
- '''Basic FeedGenerator extension.
- '''
- def extend_ns(self):
- '''Returns a dict that will be used in the namespace map for the feed.'''
- return dict()
+ '''Basic FeedGenerator extension.
+ '''
+ def extend_ns(self):
+ '''Returns a dict that will be used in the namespace map for the feed.
+ '''
+ return dict()
- def extend_rss(self, feed):
- '''Extend a RSS feed xml structure containing all previously set fields.
+ def extend_rss(self, feed):
+ '''Extend a RSS feed xml structure containing all previously set
+ fields.
- :param feed: The feed xml root element.
- :returns: The feed root element.
- '''
- return feed
+ :param feed: The feed xml root element.
+ :returns: The feed root element.
+ '''
+ return feed
+ def extend_atom(self, feed):
+ '''Extend an ATOM feed xml structure containing all previously set
+ fields.
- def extend_atom(self, feed):
- '''Extend an ATOM feed xml structure containing all previously set
- fields.
-
- :param feed: The feed xml root element.
- :returns: The feed root element.
- '''
- return feed
+ :param feed: The feed xml root element.
+ :returns: The feed root element.
+ '''
+ return feed
class BaseEntryExtension(BaseExtension):
- '''Basic FeedEntry extension.
- '''
+ '''Basic FeedEntry extension.
+ '''
diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py
index c4450a8..466c66d 100644
--- a/feedgen/ext/dc.py
+++ b/feedgen/ext/dc.py
@@ -1,449 +1,407 @@
# -*- coding: utf-8 -*-
'''
- feedgen.ext.dc
- ~~~~~~~~~~~~~~~~~~~
+ feedgen.ext.dc
+ ~~~~~~~~~~~~~~~~~~~
- Extends the FeedGenerator to add Dubline Core Elements to the feeds.
+ Extends the FeedGenerator to add Dubline Core Elements to the feeds.
- Descriptions partly taken from
- http://dublincore.org/documents/dcmi-terms/#elements-coverage
+ Descriptions partly taken from
+ http://dublincore.org/documents/dcmi-terms/#elements-coverage
- :copyright: 2013, Lars Kiesow
+ :copyright: 2013-2017, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
-from lxml import etree
-from feedgen.ext.base import BaseExtension, BaseEntryExtension
+from feedgen.ext.base import BaseExtension
+from feedgen.util import xml_elem
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_atom(self, atom_feed):
- '''Create an Atom feed xml structure containing all previously set fields.
-
- :returns: The feed root element
- '''
- DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/'
-
- feed = atom_feed
-
- for elem in ['contributor', 'coverage', 'creator', 'date', 'description',
- 'language', 'publisher', 'relation', 'rights', 'source', 'subject',
- 'title', 'type']:
- if hasattr(self, '_dcelem_%s' % elem):
- for val in getattr(self, '_dcelem_%s' % elem) or []:
- node = etree.SubElement(feed, '{%s}%s' % (DCELEMENTS_NS,elem))
- node.text = val
-
- if self._dcelem_format:
- node = etree.SubElement(feed, '{%s}format' % DCELEMENTS_NS)
- node.text = format
-
- if self._dcelem_identifier:
- node = etree.SubElement(feed, '{%s}identifier' % DCELEMENTS_NS)
- node.text = identifier
-
- return feed
-
-
- def extend_rss(self, rss_feed):
- '''Create an RSS feed xml structure containing all previously set fields.
-
- :returns: Tuple containing the feed root element and the element tree.
- '''
- DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/'
- channel = rss_feed[0]
-
- for elem in ['contributor', 'coverage', 'creator', 'date', 'description',
- 'language', 'publisher', 'relation', 'rights', 'source', 'subject',
- 'title', 'type']:
- if hasattr(self, '_dcelem_%s' % elem):
- for val in getattr(self, '_dcelem_%s' % elem) or []:
- node = etree.SubElement(channel, '{%s}%s' % (DCELEMENTS_NS,elem))
- node.text = val
-
- if self._dcelem_format:
- node = etree.SubElement(channel, '{%s}format' % DCELEMENTS_NS)
- node.text = format
-
- if self._dcelem_identifier:
- node = etree.SubElement(channel, '{%s}identifier' % DCELEMENTS_NS)
- node.text = identifier
-
- 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.
-
- If not set, the value of atom:id will be used. But setting this value
- will on the other hand not set atom:id.
-
- For more inidentifierion see:
- http://dublincore.org/documents/dcmi-terms/#elements-identifier
-
- :param identifier: Identifier of the resource or list of identifiers.
- :param replace: Replace alredy set format (deault: True).
- :returns: Identifiers of the resource.
- '''
- if not identifier is None:
- if not isinstance(identifier, list):
- identifier = [identifier]
- if replace or not self._dcelem_identifier:
- self._dcelem_identifier = []
- self._dcelem_identifier += identifier
-
-
- def dc_language(self, language=None, replace=True):
- '''Get or set the dc:language which describes a language of the resource.
-
- For more information see:
- http://dublincore.org/documents/dcmi-terms/#elements-language
-
- :param language: Language or list of languages.
- :param replace: Replace alredy set languages (deault: True).
- :returns: List of languages.
- '''
- if not language is None:
- if not isinstance(language, list):
- language = [language]
- if replace or not self._dcelem_language:
- self._dcelem_language = []
- self._dcelem_language += language
- return self._dcelem_language
-
-
- def dc_publisher(self, publisher=None, replace=False):
- '''Get or set the dc:publisher which is an entity responsible for making
- the resource available.
-
- For more information see:
- http://dublincore.org/documents/dcmi-terms/#elements-publisher
-
- :param publisher: Publisher or list of publishers.
- :param replace: Replace alredy set publishers (deault: False).
- :returns: List of publishers.
- '''
- if not publisher is None:
- if not isinstance(publisher, list):
- publisher = [publisher]
- if replace or not self._dcelem_publisher:
- self._dcelem_publisher = []
- self._dcelem_publisher += publisher
- return self._dcelem_publisher
-
-
- def dc_relation(self, relation=None, replace=False):
- '''Get or set the dc:relation which describes a related ressource.
-
- For more information see:
- http://dublincore.org/documents/dcmi-terms/#elements-relation
-
- :param relation: Relation or list of relations.
- :param replace: Replace alredy set relations (deault: False).
- :returns: List of relations.
- '''
- if not relation is None:
- if not isinstance(relation, list):
- relation = [relation]
- if replace or not self._dcelem_relation:
- self._dcelem_relation = []
- self._dcelem_relation += relation
- return self._dcelem_relation
-
-
- def dc_rights(self, rights=None, replace=False):
- '''Get or set the dc:rights which may contain information about rights
- held in and over the resource.
-
- For more information see:
- http://dublincore.org/documents/dcmi-terms/#elements-rights
-
- :param rights: Rights information or list of rights information.
- :param replace: Replace alredy set rightss (deault: False).
- :returns: List of rights information.
- '''
- if not rights is None:
- if not isinstance(rights, list):
- rights = [rights]
- if replace or not self._dcelem_rights:
- self._dcelem_rights = []
- self._dcelem_rights += rights
- return self._dcelem_rights
-
-
- def dc_source(self, source=None, replace=False):
- '''Get or set the dc:source which is a related resource from which the
- described resource is derived.
-
- The described resource may be derived from the related resource in whole
- or in part. Recommended best practice is to identify the related resource
- by means of a string conforming to a formal identification system.
-
-
- For more information see:
- http://dublincore.org/documents/dcmi-terms/#elements-source
-
- :param source: Source or list of sources.
- :param replace: Replace alredy set sources (deault: False).
- :returns: List of sources.
- '''
- if not source is None:
- if not isinstance(source, list):
- source = [source]
- if replace or not self._dcelem_source:
- self._dcelem_source = []
- self._dcelem_source += source
- return self._dcelem_source
-
-
- def dc_subject(self, subject=None, replace=False):
- '''Get or set the dc:subject which describes the topic of the resource.
-
- For more information see:
- http://dublincore.org/documents/dcmi-terms/#elements-subject
-
- :param subject: Subject or list of subjects.
- :param replace: Replace alredy set subjects (deault: False).
- :returns: List of subjects.
- '''
- if not subject is None:
- if not isinstance(subject, list):
- subject = [subject]
- if replace or not self._dcelem_subject:
- self._dcelem_subject = []
- self._dcelem_subject += subject
- return self._dcelem_subject
-
-
- def dc_title(self, title=None, replace=True):
- '''Get or set the dc:title which is a name given to the resource.
-
- For more information see:
- http://dublincore.org/documents/dcmi-terms/#elements-title
-
- :param title: Title or list of titles.
- :param replace: Replace alredy set titles (deault: False).
- :returns: List of titles.
- '''
- if not title is None:
- if not isinstance(title, list):
- title = [title]
- if replace or not self._dcelem_title:
- self._dcelem_title = []
- self._dcelem_title += title
- return self._dcelem_title
-
-
- def dc_type(self, type=None, replace=False):
- '''Get or set the dc:type which describes the nature or genre of the
- resource.
-
- For more information see:
- http://dublincore.org/documents/dcmi-terms/#elements-type
-
- :param type: Type or list of types.
- :param replace: Replace alredy set types (deault: False).
- :returns: List of types.
- '''
- if not type is None:
- if not isinstance(type, list):
- type = [type]
- if replace or not self._dcelem_type:
- self._dcelem_type = []
- self._dcelem_type += type
- return self._dcelem_type
+ '''Dublin Core Elements extension for podcasts.
+ '''
+
+ def __init__(self):
+ # http://dublincore.org/documents/usageguide/elements.shtml
+ # http://dublincore.org/documents/dces/
+ # http://dublincore.org/documents/dcmi-terms/
+ self._dcelem_contributor = None
+ self._dcelem_coverage = None
+ self._dcelem_creator = None
+ self._dcelem_date = None
+ self._dcelem_description = None
+ self._dcelem_format = None
+ self._dcelem_identifier = None
+ self._dcelem_language = None
+ self._dcelem_publisher = None
+ self._dcelem_relation = None
+ self._dcelem_rights = None
+ self._dcelem_source = None
+ self._dcelem_subject = None
+ self._dcelem_title = None
+ self._dcelem_type = None
+
+ def extend_ns(self):
+ return {'dc': 'http://purl.org/dc/elements/1.1/'}
+
+ def _extend_xml(self, xml_element):
+ '''Extend xml_element with set DC fields.
+
+ :param xml_element: 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 = xml_elem('{%s}%s' % (DCELEMENTS_NS, elem),
+ xml_element)
+ 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 already set contributors (default: False).
+ :returns: List of contributors.
+ '''
+ if contributor is not None:
+ if not isinstance(contributor, list):
+ contributor = [contributor]
+ if replace or not self._dcelem_contributor:
+ self._dcelem_contributor = []
+ self._dcelem_contributor += contributor
+ return self._dcelem_contributor
+
+ def dc_coverage(self, coverage=None, replace=True):
+ '''Get or set the dc:coverage which indicated the spatial or temporal
+ topic of the resource, the spatial applicability of the resource, or
+ the jurisdiction under which the resource is relevant.
+
+ Spatial topic and spatial applicability may be a named place or a
+ location specified by its geographic coordinates. Temporal topic may be
+ a named period, date, or date range. A jurisdiction may be a named
+ administrative entity or a geographic place to which the resource
+ applies. Recommended best practice is to use a controlled vocabulary
+ such as the Thesaurus of Geographic Names [TGN]. Where appropriate,
+ named places or time periods can be used in preference to numeric
+ identifiers such as sets of coordinates or date ranges.
+
+ References:
+ [TGN] http://www.getty.edu/research/tools/vocabulary/tgn/index.html
+
+ :param coverage: Coverage of the feed.
+ :param replace: Replace already set coverage (default: True).
+ :returns: Coverage of the feed.
+ '''
+ if coverage is not None:
+ if not isinstance(coverage, list):
+ coverage = [coverage]
+ if replace or not self._dcelem_coverage:
+ self._dcelem_coverage = []
+ self._dcelem_coverage = coverage
+ return self._dcelem_coverage
+
+ def dc_creator(self, creator=None, replace=False):
+ '''Get or set the dc:creator which is an entity primarily responsible
+ for making the resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-creator
+
+ :param creator: Creator or list of creators.
+ :param replace: Replace already set creators (default: False).
+ :returns: List of creators.
+ '''
+ if creator is not None:
+ if not isinstance(creator, list):
+ creator = [creator]
+ if replace or not self._dcelem_creator:
+ self._dcelem_creator = []
+ self._dcelem_creator += creator
+ return self._dcelem_creator
+
+ def dc_date(self, date=None, replace=True):
+ '''Get or set the dc:date which describes a point or period of time
+ associated with an event in the lifecycle of the resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-date
+
+ :param date: Date or list of dates.
+ :param replace: Replace already set dates (default: True).
+ :returns: List of dates.
+ '''
+ if date is not None:
+ if not isinstance(date, list):
+ date = [date]
+ if replace or not self._dcelem_date:
+ self._dcelem_date = []
+ self._dcelem_date += date
+ return self._dcelem_date
+
+ def dc_description(self, description=None, replace=True):
+ '''Get or set the dc:description which is an account of the resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-description
+
+ :param description: Description or list of descriptions.
+ :param replace: Replace already set descriptions (default: True).
+ :returns: List of descriptions.
+ '''
+ if description is not None:
+ if not isinstance(description, list):
+ description = [description]
+ if replace or not self._dcelem_description:
+ self._dcelem_description = []
+ self._dcelem_description += description
+ return self._dcelem_description
+
+ def dc_format(self, format=None, replace=True):
+ '''Get or set the dc:format which describes the file format, physical
+ medium, or dimensions of the resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-format
+
+ :param format: Format of the resource or list of formats.
+ :param replace: Replace already set format (default: True).
+ :returns: Format of the resource.
+ '''
+ if format is not None:
+ if not isinstance(format, list):
+ format = [format]
+ if replace or not self._dcelem_format:
+ self._dcelem_format = []
+ self._dcelem_format += format
+ return self._dcelem_format
+
+ def dc_identifier(self, identifier=None, replace=True):
+ '''Get or set the dc:identifier which should be an unambiguous
+ reference to the resource within a given context.
+
+ For more inidentifierion see:
+ http://dublincore.org/documents/dcmi-terms/#elements-identifier
+
+ :param identifier: Identifier of the resource or list of identifiers.
+ :param replace: Replace already set identifier (default: True).
+ :returns: Identifiers of the resource.
+ '''
+ if identifier is not None:
+ if not isinstance(identifier, list):
+ identifier = [identifier]
+ if replace or not self._dcelem_identifier:
+ self._dcelem_identifier = []
+ self._dcelem_identifier += identifier
+ return self._dcelem_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 already set languages (default: True).
+ :returns: List of languages.
+ '''
+ if language is not None:
+ if not isinstance(language, list):
+ language = [language]
+ if replace or not self._dcelem_language:
+ self._dcelem_language = []
+ self._dcelem_language += language
+ return self._dcelem_language
+
+ def dc_publisher(self, publisher=None, replace=False):
+ '''Get or set the dc:publisher which is an entity responsible for
+ making the resource available.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-publisher
+
+ :param publisher: Publisher or list of publishers.
+ :param replace: Replace already set publishers (default: False).
+ :returns: List of publishers.
+ '''
+ if publisher is not None:
+ if not isinstance(publisher, list):
+ publisher = [publisher]
+ if replace or not self._dcelem_publisher:
+ self._dcelem_publisher = []
+ self._dcelem_publisher += publisher
+ return self._dcelem_publisher
+
+ def dc_relation(self, relation=None, replace=False):
+ '''Get or set the dc:relation which describes a related resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-relation
+
+ :param relation: Relation or list of relations.
+ :param replace: Replace already set relations (default: False).
+ :returns: List of relations.
+ '''
+ if relation is not None:
+ if not isinstance(relation, list):
+ relation = [relation]
+ if replace or not self._dcelem_relation:
+ self._dcelem_relation = []
+ self._dcelem_relation += relation
+ return self._dcelem_relation
+
+ def dc_rights(self, rights=None, replace=False):
+ '''Get or set the dc:rights which may contain information about rights
+ held in and over the resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-rights
+
+ :param rights: Rights information or list of rights information.
+ :param replace: Replace already set rights (default: False).
+ :returns: List of rights information.
+ '''
+ if rights is not None:
+ if not isinstance(rights, list):
+ rights = [rights]
+ if replace or not self._dcelem_rights:
+ self._dcelem_rights = []
+ self._dcelem_rights += rights
+ return self._dcelem_rights
+
+ def dc_source(self, source=None, replace=False):
+ '''Get or set the dc:source which is a related resource from which the
+ described resource is derived.
+
+ The described resource may be derived from the related resource in
+ whole or in part. Recommended best practice is to identify the related
+ resource by means of a string conforming to a formal identification
+ system.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-source
+
+ :param source: Source or list of sources.
+ :param replace: Replace already set sources (default: False).
+ :returns: List of sources.
+ '''
+ if source is not None:
+ if not isinstance(source, list):
+ source = [source]
+ if replace or not self._dcelem_source:
+ self._dcelem_source = []
+ self._dcelem_source += source
+ return self._dcelem_source
+
+ def dc_subject(self, subject=None, replace=False):
+ '''Get or set the dc:subject which describes the topic of the resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-subject
+
+ :param subject: Subject or list of subjects.
+ :param replace: Replace already set subjects (default: False).
+ :returns: List of subjects.
+ '''
+ if subject is not None:
+ if not isinstance(subject, list):
+ subject = [subject]
+ if replace or not self._dcelem_subject:
+ self._dcelem_subject = []
+ self._dcelem_subject += subject
+ return self._dcelem_subject
+
+ def dc_title(self, title=None, replace=True):
+ '''Get or set the dc:title which is a name given to the resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-title
+
+ :param title: Title or list of titles.
+ :param replace: Replace already set titles (default: False).
+ :returns: List of titles.
+ '''
+ if title is not None:
+ if not isinstance(title, list):
+ title = [title]
+ if replace or not self._dcelem_title:
+ self._dcelem_title = []
+ self._dcelem_title += title
+ return self._dcelem_title
+
+ def dc_type(self, type=None, replace=False):
+ '''Get or set the dc:type which describes the nature or genre of the
+ resource.
+
+ For more information see:
+ http://dublincore.org/documents/dcmi-terms/#elements-type
+
+ :param type: Type or list of types.
+ :param replace: Replace already set types (default: False).
+ :returns: List of types.
+ '''
+ if type is not None:
+ if not isinstance(type, list):
+ type = [type]
+ if replace or not self._dcelem_type:
+ self._dcelem_type = []
+ self._dcelem_type += type
+ return self._dcelem_type
+
class DcExtension(DcBaseExtension):
- '''Dublin Core Elements extension for podcasts.
- '''
+ '''Dublin Core Elements extension for podcasts.
+ '''
+
class DcEntryExtension(DcBaseExtension):
- '''Dublin Core Elements extension for podcasts.
- '''
- def extend_atom(self, entry):
- '''NYI. Differs from RSS Implementation?
-
- '''
- return entry
-
- def extend_rss(self, item):
- '''Add dc elements to a RSS item. Alters the item itself.
-
- :returns: The item element.
- '''
- DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/'
-
- for elem in ['contributor', 'coverage', 'creator', 'date', 'description',
- 'language', 'publisher', 'relation', 'rights', 'source', 'subject',
- 'title', 'type']:
- if hasattr(self, '_dcelem_%s' % elem):
- for val in getattr(self, '_dcelem_%s' % elem) or []:
- node = etree.SubElement(item, '{%s}%s' % (DCELEMENTS_NS,elem))
- node.text = val
-
- if self._dcelem_format:
- node = etree.SubElement(item, '{%s}format' % DCELEMENTS_NS)
- node.text = format
-
- if self._dcelem_identifier:
- node = etree.SubElement(item, '{%s}identifier' % DCELEMENTS_NS)
- node.text = identifier
-
- return item
+ '''Dublin Core Elements extension for podcasts.
+ '''
+ def extend_atom(self, entry):
+ '''Add dc elements to an atom item. Alters the item itself.
+
+ :param entry: An atom entry element.
+ :returns: The entry element.
+ '''
+ self._extend_xml(entry)
+ return entry
+
+ def extend_rss(self, item):
+ '''Add dc elements to a RSS item. Alters the item itself.
+
+ :param item: A RSS item element.
+ :returns: The item element.
+ '''
+ self._extend_xml(item)
+ return item
diff --git a/feedgen/ext/geo.py b/feedgen/ext/geo.py
new file mode 100644
index 0000000..b6384d4
--- /dev/null
+++ b/feedgen/ext/geo.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+'''
+ feedgen.ext.geo
+ ~~~~~~~~~~~~~~~~~~~
+
+ Extends the FeedGenerator to produce Simple GeoRSS feeds.
+
+ :copyright: 2017, Bob Breznak
+
+ :license: FreeBSD and LGPL, see license.* for more details.
+'''
+
+from feedgen.ext.base import BaseExtension
+
+
+class GeoExtension(BaseExtension):
+ '''FeedGenerator extension for Simple GeoRSS.
+ '''
+
+ def extend_ns(self):
+ return {'georss': 'http://www.georss.org/georss'}
diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py
new file mode 100644
index 0000000..4c18cfe
--- /dev/null
+++ b/feedgen/ext/geo_entry.py
@@ -0,0 +1,329 @@
+# -*- coding: utf-8 -*-
+'''
+ feedgen.ext.geo_entry
+ ~~~~~~~~~~~~~~~~~~~
+
+ Extends the FeedGenerator to produce Simple GeoRSS feeds.
+
+ :copyright: 2017, Bob Breznak
+
+ :license: FreeBSD and LGPL, see license.* for more details.
+'''
+import numbers
+import warnings
+
+from feedgen.ext.base import BaseEntryExtension
+from feedgen.util import xml_elem
+
+
+class GeoRSSPolygonInteriorWarning(Warning):
+ """
+ Simple placeholder for warning about ignored polygon interiors.
+
+ Stores the original geom on a ``geom`` attribute (if required warnings are
+ raised as errors).
+ """
+
+ def __init__(self, geom, *args, **kwargs):
+ self.geom = geom
+ super(GeoRSSPolygonInteriorWarning, self).__init__(*args, **kwargs)
+
+ def __str__(self):
+ return '{:d} interiors of polygon ignored'.format(
+ len(self.geom.__geo_interface__['coordinates']) - 1
+ ) # ignore exterior in count
+
+
+class GeoRSSGeometryError(ValueError):
+ """
+ Subclass of ValueError for a GeoRSS geometry error
+
+ Only some geometries are supported in Simple GeoRSS, so if not raise an
+ error. Offending geometry is stored on the ``geom`` attribute.
+ """
+
+ def __init__(self, geom, *args, **kwargs):
+ self.geom = geom
+ super(GeoRSSGeometryError, self).__init__(*args, **kwargs)
+
+ def __str__(self):
+ msg = "Geometry of type '{}' not in Point, Linestring or Polygon"
+ return msg.format(
+ self.geom.__geo_interface__['type']
+ )
+
+
+class GeoEntryExtension(BaseEntryExtension):
+ '''FeedEntry extension for Simple GeoRSS.
+ '''
+
+ def __init__(self):
+ '''Simple GeoRSS tag'''
+ # geometries
+ self.__point = None
+ self.__line = None
+ self.__polygon = None
+ self.__box = None
+
+ # additional properties
+ self.__featuretypetag = None
+ self.__relationshiptag = None
+ self.__featurename = None
+
+ # elevation
+ self.__elev = None
+ self.__floor = None
+
+ # radius
+ self.__radius = None
+
+ def extend_file(self, entry):
+ '''Add additional fields to an RSS item.
+
+ :param feed: The RSS item XML element to use.
+ '''
+
+ GEO_NS = 'http://www.georss.org/georss'
+
+ if self.__point:
+ point = xml_elem('{%s}point' % GEO_NS, entry)
+ point.text = self.__point
+
+ if self.__line:
+ line = xml_elem('{%s}line' % GEO_NS, entry)
+ line.text = self.__line
+
+ if self.__polygon:
+ polygon = xml_elem('{%s}polygon' % GEO_NS, entry)
+ polygon.text = self.__polygon
+
+ if self.__box:
+ box = xml_elem('{%s}box' % GEO_NS, entry)
+ box.text = self.__box
+
+ if self.__featuretypetag:
+ featuretypetag = xml_elem('{%s}featuretypetag' % GEO_NS, entry)
+ featuretypetag.text = self.__featuretypetag
+
+ if self.__relationshiptag:
+ relationshiptag = xml_elem('{%s}relationshiptag' % GEO_NS, entry)
+ relationshiptag.text = self.__relationshiptag
+
+ if self.__featurename:
+ featurename = xml_elem('{%s}featurename' % GEO_NS, entry)
+ featurename.text = self.__featurename
+
+ if self.__elev:
+ elevation = xml_elem('{%s}elev' % GEO_NS, entry)
+ elevation.text = str(self.__elev)
+
+ if self.__floor:
+ floor = xml_elem('{%s}floor' % GEO_NS, entry)
+ floor.text = str(self.__floor)
+
+ if self.__radius:
+ radius = xml_elem('{%s}radius' % GEO_NS, entry)
+ radius.text = str(self.__radius)
+
+ return entry
+
+ def extend_rss(self, entry):
+ return self.extend_file(entry)
+
+ def extend_atom(self, entry):
+ return self.extend_file(entry)
+
+ def point(self, point=None):
+ '''Get or set the georss:point of the entry.
+
+ :param point: The GeoRSS formatted point (i.e. "42.36 -71.05")
+ :returns: The current georss:point of the entry.
+ '''
+
+ if point is not None:
+ self.__point = point
+
+ return self.__point
+
+ def line(self, line=None):
+ '''Get or set the georss:line of the entry
+
+ :param point: The GeoRSS formatted line (i.e. "45.256 -110.45 46.46
+ -109.48 43.84 -109.86")
+ :return: The current georss:line of the entry
+ '''
+ if line is not None:
+ self.__line = line
+
+ return self.__line
+
+ def polygon(self, polygon=None):
+ '''Get or set the georss:polygon of the entry
+
+ :param polygon: The GeoRSS formatted polygon (i.e. "45.256 -110.45
+ 46.46 -109.48 43.84 -109.86 45.256 -110.45")
+ :return: The current georss:polygon of the entry
+ '''
+ if polygon is not None:
+ self.__polygon = polygon
+
+ return self.__polygon
+
+ def box(self, box=None):
+ '''
+ Get or set the georss:box of the entry
+
+ :param box: The GeoRSS formatted box (i.e. "42.943 -71.032 43.039
+ -69.856")
+ :return: The current georss:box of the entry
+ '''
+ if box is not None:
+ self.__box = box
+
+ return self.__box
+
+ def featuretypetag(self, featuretypetag=None):
+ '''
+ Get or set the georss:featuretypetag of the entry
+
+ :param featuretypetag: The GeoRSS feaaturertyptag (e.g. "city")
+ :return: The current georss:featurertypetag
+ '''
+ if featuretypetag is not None:
+ self.__featuretypetag = featuretypetag
+
+ return self.__featuretypetag
+
+ def relationshiptag(self, relationshiptag=None):
+ '''
+ Get or set the georss:relationshiptag of the entry
+
+ :param relationshiptag: The GeoRSS relationshiptag (e.g.
+ "is-centred-at")
+ :return: the current georss:relationshiptag
+ '''
+ if relationshiptag is not None:
+ self.__relationshiptag = relationshiptag
+
+ return self.__relationshiptag
+
+ def featurename(self, featurename=None):
+ '''
+ Get or set the georss:featurename of the entry
+
+ :param featuretypetag: The GeoRSS featurename (e.g. "Footscray")
+ :return: the current georss:featurename
+ '''
+ if featurename is not None:
+ self.__featurename = featurename
+
+ return self.__featurename
+
+ def elev(self, elev=None):
+ '''
+ Get or set the georss:elev of the entry
+
+ :param elev: The GeoRSS elevation (e.g. 100.3)
+ :type elev: numbers.Number
+ :return: the current georss:elev
+ '''
+ if elev is not None:
+ if not isinstance(elev, numbers.Number):
+ raise ValueError("elev tag must be numeric: {}".format(elev))
+
+ self.__elev = elev
+
+ return self.__elev
+
+ def floor(self, floor=None):
+ '''
+ Get or set the georss:floor of the entry
+
+ :param floor: The GeoRSS floor (e.g. 4)
+ :type floor: int
+ :return: the current georss:floor
+ '''
+ if floor is not None:
+ if not isinstance(floor, int):
+ raise ValueError("floor tag must be int: {}".format(floor))
+
+ self.__floor = floor
+
+ return self.__floor
+
+ def radius(self, radius=None):
+ '''
+ Get or set the georss:radius of the entry
+
+ :param radius: The GeoRSS radius (e.g. 100.3)
+ :type radius: numbers.Number
+ :return: the current georss:radius
+ '''
+ if radius is not None:
+ if not isinstance(radius, numbers.Number):
+ raise ValueError(
+ "radius tag must be numeric: {}".format(radius)
+ )
+
+ self.__radius = radius
+
+ return self.__radius
+
+ def geom_from_geo_interface(self, geom):
+ '''
+ Generate a georss geometry from some Python object with a
+ ``__geo_interface__`` property (see the `geo_interface specification by
+ Sean Gillies`_geointerface )
+
+ Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be
+ easily converted to GeoRSS:
+
+ - Point
+ - LineString
+ - Polygon (if there are holes / donuts in the polygons a warning will
+ be generated
+
+ Other GeoJson types will raise a ``ValueError``.
+
+ .. note:: The geometry is assumed to be x, y as longitude, latitude in
+ the WGS84 projection.
+
+ .. _geointerface: https://gist.github.com/sgillies/2217756
+ .. _geojson: https://geojson.org/
+
+ :param geom: Geometry object with a __geo_interface__ property
+ :return: the formatted GeoRSS geometry
+ '''
+ geojson = geom.__geo_interface__
+
+ if geojson['type'] not in ('Point', 'LineString', 'Polygon'):
+ raise GeoRSSGeometryError(geom)
+
+ if geojson['type'] == 'Point':
+
+ coords = '{:f} {:f}'.format(
+ geojson['coordinates'][1], # latitude is y
+ geojson['coordinates'][0]
+ )
+ return self.point(coords)
+
+ elif geojson['type'] == 'LineString':
+
+ coords = ' '.join(
+ '{:f} {:f}'.format(vertex[1], vertex[0])
+ for vertex in
+ geojson['coordinates']
+ )
+ return self.line(coords)
+
+ elif geojson['type'] == 'Polygon':
+
+ if len(geojson['coordinates']) > 1:
+ warnings.warn(GeoRSSPolygonInteriorWarning(geom))
+
+ coords = ' '.join(
+ '{:f} {:f}'.format(vertex[1], vertex[0])
+ for vertex in
+ geojson['coordinates'][0]
+ )
+ return self.polygon(coords)
diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py
new file mode 100644
index 0000000..74a5317
--- /dev/null
+++ b/feedgen/ext/media.py
@@ -0,0 +1,183 @@
+# -*- coding: utf-8 -*-
+'''
+ feedgen.ext.media
+ ~~~~~~~~~~~~~~~~~
+
+ Extends the feedgen to produce media tags.
+
+ :copyright: 2013-2017, Lars Kiesow
+
+ :license: FreeBSD and LGPL, see license.* for more details.
+'''
+
+from feedgen.ext.base import BaseEntryExtension, BaseExtension
+from feedgen.util import ensure_format, xml_elem
+
+MEDIA_NS = 'http://search.yahoo.com/mrss/'
+
+
+class MediaExtension(BaseExtension):
+ '''FeedGenerator extension for torrent feeds.
+ '''
+
+ def extend_ns(self):
+ return {'media': MEDIA_NS}
+
+
+class MediaEntryExtension(BaseEntryExtension):
+ '''FeedEntry extension for media tags.
+ '''
+
+ def __init__(self):
+ self.__media_content = []
+ self.__media_thumbnail = []
+
+ def extend_atom(self, entry):
+ '''Add additional fields to an RSS item.
+
+ :param feed: The RSS item XML element to use.
+ '''
+
+ groups = {None: entry}
+ for media_content in self.__media_content:
+ # Define current media:group
+ group = groups.get(media_content.get('group'))
+ if group is None:
+ group = xml_elem('{%s}group' % MEDIA_NS, entry)
+ groups[media_content.get('group')] = group
+ # Add content
+ content = xml_elem('{%s}content' % MEDIA_NS, group)
+ for attr in ('url', 'fileSize', 'type', 'medium', 'isDefault',
+ 'expression', 'bitrate', 'framerate', 'samplingrate',
+ 'channels', 'duration', 'height', 'width', 'lang'):
+ if media_content.get(attr):
+ content.set(attr, media_content[attr])
+
+ for media_thumbnail in self.__media_thumbnail:
+ # Define current media:group
+ group = groups.get(media_thumbnail.get('group'))
+ if group is None:
+ group = xml_elem('{%s}group' % MEDIA_NS, entry)
+ groups[media_thumbnail.get('group')] = group
+ # Add thumbnails
+ thumbnail = xml_elem('{%s}thumbnail' % MEDIA_NS, group)
+ for attr in ('url', 'height', 'width', 'time'):
+ if media_thumbnail.get(attr):
+ thumbnail.set(attr, media_thumbnail[attr])
+
+ return entry
+
+ def extend_rss(self, item):
+ return self.extend_atom(item)
+
+ def content(self, content=None, replace=False, group='default', **kwargs):
+ '''Get or set media:content data.
+
+ This method can be called with:
+ - the fields of a media:content as keyword arguments
+ - the fields of a media:content as a dictionary
+ - a list of dictionaries containing the media:content fields
+
+ is a sub-element of either - or .
+ Media objects that are not the same content should not be included in
+ the same element. The sequence of these items implies
+ the order of presentation. While many of the attributes appear to be
+ audio/video specific, this element can be used to publish any type
+ of media. It contains 14 attributes, most of which are optional.
+
+ media:content has the following fields:
+ - *url* should specify the direct URL to the media object.
+ - *fileSize* number of bytes of the media object.
+ - *type* standard MIME type of the object.
+ - *medium* type of object (image | audio | video | document |
+ executable).
+ - *isDefault* determines if this is the default object.
+ - *expression* determines if the object is a sample or the full version
+ of the object, or even if it is a continuous stream (sample | full |
+ nonstop).
+ - *bitrate* kilobits per second rate of media.
+ - *framerate* number of frames per second for the media object.
+ - *samplingrate* number of samples per second taken to create the media
+ object. It is expressed in thousands of samples per second (kHz).
+ - *channels* number of audio channels in the media object.
+ - *duration* number of seconds the media object plays.
+ - *height* height of the media object.
+ - *width* width of the media object.
+ - *lang* is the primary language encapsulated in the media object.
+
+ :param content: Dictionary or list of dictionaries with content data.
+ :param replace: Add or replace old data.
+ :param group: Media group to put this content in.
+
+ :returns: The media content tag.
+ '''
+ # Handle kwargs
+ if content is None and kwargs:
+ content = kwargs
+ # Handle new data
+ if content is not None:
+ # Reset data if we want to replace them
+ if replace or self.__media_content is None:
+ self.__media_content = []
+ # Ensure list
+ if not isinstance(content, list):
+ content = [content]
+ # define media group
+ for c in content:
+ c['group'] = c.get('group', group)
+ self.__media_content += ensure_format(
+ content,
+ set(['url', 'fileSize', 'type', 'medium', 'isDefault',
+ 'expression', 'bitrate', 'framerate', 'samplingrate',
+ 'channels', 'duration', 'height', 'width', 'lang',
+ 'group']),
+ set(['url', 'group']))
+ return self.__media_content
+
+ def thumbnail(self, thumbnail=None, replace=False, group='default',
+ **kwargs):
+ '''Get or set media:thumbnail data.
+
+ This method can be called with:
+ - the fields of a media:content as keyword arguments
+ - the fields of a media:content as a dictionary
+ - a list of dictionaries containing the media:content fields
+
+ Allows particular images to be used as representative images for
+ the media object. If multiple thumbnails are included, and time
+ coding is not at play, it is assumed that the images are in order
+ of importance. It has one required attribute and three optional
+ attributes.
+
+ media:thumbnail has the following fields:
+ - *url* should specify the direct URL to the media object.
+ - *height* height of the media object.
+ - *width* width of the media object.
+ - *time* specifies the time offset in relation to the media object.
+
+ :param thumbnail: Dictionary or list of dictionaries with thumbnail
+ data.
+ :param replace: Add or replace old data.
+ :param group: Media group to put this content in.
+
+ :returns: The media thumbnail tag.
+ '''
+ # Handle kwargs
+ if thumbnail is None and kwargs:
+ thumbnail = kwargs
+ # Handle new data
+ if thumbnail is not None:
+ # Reset data if we want to replace them
+ if replace or self.__media_thumbnail is None:
+ self.__media_thumbnail = []
+ # Ensure list
+ if not isinstance(thumbnail, list):
+ thumbnail = [thumbnail]
+ # Define media group
+ for t in thumbnail:
+ t['group'] = t.get('group', group)
+ self.__media_thumbnail += ensure_format(
+ thumbnail,
+ set(['url', 'height', 'width', 'time', 'group']),
+ set(['url', 'group']))
+ return self.__media_thumbnail
diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py
index 14a8a95..c535392 100644
--- a/feedgen/ext/podcast.py
+++ b/feedgen/ext/podcast.py
@@ -1,314 +1,388 @@
# -*- coding: utf-8 -*-
'''
- feedgen.ext.podcast
- ~~~~~~~~~~~~~~~~~~~
+ feedgen.ext.podcast
+ ~~~~~~~~~~~~~~~~~~~
- Extends the FeedGenerator to produce podcasts.
+ Extends the FeedGenerator to produce podcasts.
- :copyright: 2013, Lars Kiesow
+ :copyright: 2013, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
-from lxml import etree
+from feedgen.compat import string_types
from feedgen.ext.base import BaseExtension
+from feedgen.util import ensure_format, xml_elem
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):
- '''Create an RSS feed xml structure containing all previously set fields.
-
- :returns: Tuple containing the feed root element and the element tree.
- '''
- ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd'
- feed = rss_feed
- channel = 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 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' : []
- }
+ '''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
+ self.__itunes_type = 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 = xml_elem('{%s}author' % ITUNES_NS, channel)
+ author.text = self.__itunes_author
+
+ if self.__itunes_block is not None:
+ block = xml_elem('{%s}block' % ITUNES_NS, channel)
+ block.text = 'yes' if self.__itunes_block else 'no'
+
+ for c in self.__itunes_category or []:
+ if not c.get('cat'):
+ continue
+ category = channel.find(
+ '{%s}category[@text="%s"]' % (ITUNES_NS, c.get('cat')))
+ if category is None:
+ category = xml_elem('{%s}category' % ITUNES_NS, channel)
+ category.attrib['text'] = c.get('cat')
+
+ if c.get('sub'):
+ subcategory = xml_elem('{%s}category' % ITUNES_NS, category)
+ subcategory.attrib['text'] = c.get('sub')
+
+ if self.__itunes_image:
+ image = xml_elem('{%s}image' % ITUNES_NS, channel)
+ image.attrib['href'] = self.__itunes_image
+
+ if self.__itunes_explicit in ('yes', 'no', 'clean'):
+ explicit = xml_elem('{%s}explicit' % ITUNES_NS, channel)
+ explicit.text = self.__itunes_explicit
+
+ if self.__itunes_complete in ('yes', 'no'):
+ complete = xml_elem('{%s}complete' % ITUNES_NS, channel)
+ complete.text = self.__itunes_complete
+
+ if self.__itunes_new_feed_url:
+ new_feed_url = xml_elem('{%s}new-feed-url' % ITUNES_NS, channel)
+ new_feed_url.text = self.__itunes_new_feed_url
+
+ if self.__itunes_owner:
+ owner = xml_elem('{%s}owner' % ITUNES_NS, channel)
+ owner_name = xml_elem('{%s}name' % ITUNES_NS, owner)
+ owner_name.text = self.__itunes_owner.get('name')
+ owner_email = xml_elem('{%s}email' % ITUNES_NS, owner)
+ owner_email.text = self.__itunes_owner.get('email')
+
+ if self.__itunes_subtitle:
+ subtitle = xml_elem('{%s}subtitle' % ITUNES_NS, channel)
+ subtitle.text = self.__itunes_subtitle
+
+ if self.__itunes_summary:
+ summary = xml_elem('{%s}summary' % ITUNES_NS, channel)
+ summary.text = self.__itunes_summary
+
+ if self.__itunes_type in ('episodic', 'serial'):
+ type = xml_elem('{%s}type' % ITUNES_NS, channel)
+ type.text = self.__itunes_type
+
+ return rss_feed
+
+ def itunes_author(self, itunes_author=None):
+ '''Get or set the itunes:author. The content of this tag is shown in
+ the Artist column in iTunes. If the tag is not present, iTunes uses the
+ contents of the tag. If is not present at the
+ feed level, iTunes will use the contents of .
+
+ :param itunes_author: The author of the podcast.
+ :returns: The author of the podcast.
+ '''
+ if itunes_author is not None:
+ self.__itunes_author = itunes_author
+ return self.__itunes_author
+
+ def itunes_block(self, itunes_block=None):
+ '''Get or set the ITunes block attribute. Use this to prevent the
+ entire podcast from appearing in the iTunes podcast directory.
+
+ :param itunes_block: Block the podcast.
+ :returns: If the podcast is blocked.
+ '''
+ if itunes_block is not None:
+ self.__itunes_block = itunes_block
+ return self.__itunes_block
+
+ def itunes_category(self, itunes_category=None, replace=False, **kwargs):
+ '''Get or set the ITunes category which appears in the category column
+ and in iTunes Store Browser.
+
+ The (sub-)category has to be one from the values defined at
+ http://www.apple.com/itunes/podcasts/specs.html#categories
+
+ This method can be called with:
+
+ - the fields of an itunes_category as keyword arguments
+ - the fields of an itunes_category as a dictionary
+ - a list of dictionaries containing the itunes_category fields
+
+ An itunes_category has the following fields:
+
+ - *cat* name for a category.
+ - *sub* name for a subcategory, child of category
+
+ If a podcast has more than one subcategory from the same category, the
+ category is called more than once.
+
+ Likei the parameter::
+
+ [{"cat":"Arts","sub":"Design"},{"cat":"Arts","sub":"Food"}]
+
+ …would become::
+
+
+
+
+
+
+
+ :param itunes_category: Dictionary or list of dictionaries with
+ itunes_category data.
+ :param replace: Add or replace old data.
+ :returns: List of itunes_categories as dictionaries.
+
+ ---
+
+ **Important note about deprecated parameter syntax:** Old version of
+ the feedgen did only support one category plus one subcategory which
+ would be passed to this ducntion as first two parameters. For
+ compatibility reasons, this still works but should not be used any may
+ be removed at any time.
+ '''
+ # Ensure old API still works for now. Note that the API is deprecated
+ # and this fallback may be removed at any time.
+ if isinstance(itunes_category, string_types):
+ itunes_category = {'cat': itunes_category}
+ if replace:
+ itunes_category['sub'] = replace
+ replace = True
+ if itunes_category is None and kwargs:
+ itunes_category = kwargs
+ if itunes_category is not None:
+ if replace or self.__itunes_category is None:
+ self.__itunes_category = []
+ self.__itunes_category += ensure_format(itunes_category,
+ set(['cat', 'sub']),
+ set(['cat']))
+ return self.__itunes_category
+
+ def itunes_image(self, itunes_image=None):
+ '''Get or set the image for the podcast. This tag specifies the artwork
+ for your podcast. Put the URL to the image in the href attribute.
+ iTunes prefers square .jpg images that are at least 1400x1400 pixels,
+ which is different from what is specified for the standard RSS image
+ tag. In order for a podcast to be eligible for an iTunes Store feature,
+ the accompanying image must be at least 1400x1400 pixels.
+
+ iTunes supports images in JPEG and PNG formats with an RGB color space
+ (CMYK is not supported). The URL must end in ".jpg" or ".png". If the
+ tag is not present, iTunes will use the contents of the
+ RSS image tag.
+
+ If you change your podcast’s image, also change the file’s name. iTunes
+ may not change the image if it checks your feed and the image URL is
+ the same. The server hosting your cover art image must allow HTTP head
+ requests for iTS to be able to automatically update your cover art.
+
+ :param itunes_image: Image of the podcast.
+ :returns: Image of the podcast.
+ '''
+ if itunes_image is not None:
+ if itunes_image.endswith('.jpg') or itunes_image.endswith('.png'):
+ self.__itunes_image = itunes_image
+ else:
+ ValueError('Image file must be png or jpg')
+ return self.__itunes_image
+
+ def itunes_explicit(self, itunes_explicit=None):
+ '''Get or the the itunes:explicit value of the podcast. This tag should
+ be used to indicate whether your podcast contains explicit material.
+ The three values for this tag are "yes", "no", and "clean".
+
+ If you populate this tag with "yes", an "explicit" parental advisory
+ graphic will appear next to your podcast artwork on the iTunes Store
+ and in the Name column in iTunes. If the value is "clean", the parental
+ advisory type is considered Clean, meaning that no explicit language or
+ adult content is included anywhere in the episodes, and a "clean"
+ graphic will appear. If the explicit tag is present and has any other
+ value (e.g., "no"), you see no indicator — blank is the default
+ advisory type.
+
+ :param itunes_explicit: If the podcast contains explicit material.
+ :returns: If the podcast contains explicit material.
+ '''
+ if itunes_explicit is not None:
+ if itunes_explicit not in ('', 'yes', 'no', 'clean'):
+ raise ValueError('Invalid value for explicit tag')
+ self.__itunes_explicit = itunes_explicit
+ return self.__itunes_explicit
+
+ def itunes_complete(self, itunes_complete=None):
+ '''Get or set the itunes:complete value of the podcast. This tag can be
+ used to indicate the completion of a podcast.
+
+ If you populate this tag with "yes", you are indicating that no more
+ episodes will be added to the podcast. If the tag is
+ present and has any other value (e.g. “no”), it will have no effect on
+ the podcast.
+
+ :param itunes_complete: If the podcast is complete.
+ :returns: If the podcast is complete.
+ '''
+ if itunes_complete is not None:
+ if itunes_complete not in ('yes', 'no', '', True, False):
+ raise ValueError('Invalid value for complete tag')
+ if itunes_complete is True:
+ itunes_complete = 'yes'
+ if itunes_complete is False:
+ itunes_complete = 'no'
+ self.__itunes_complete = itunes_complete
+ return self.__itunes_complete
+
+ def itunes_new_feed_url(self, itunes_new_feed_url=None):
+ '''Get or set the new-feed-url property of the podcast. This tag allows
+ you to change the URL where the podcast feed is located
+
+ After adding the tag to your old feed, you should maintain the old feed
+ for 48 hours before retiring it. At that point, iTunes will have
+ updated the directory with the new feed URL.
+
+ :param itunes_new_feed_url: New feed URL.
+ :returns: New feed URL.
+ '''
+ if itunes_new_feed_url is not None:
+ self.__itunes_new_feed_url = itunes_new_feed_url
+ return self.__itunes_new_feed_url
+
+ def itunes_owner(self, name=None, email=None):
+ '''Get or set the itunes:owner of the podcast. This tag contains
+ information that will be used to contact the owner of the podcast for
+ communication specifically about the podcast. It will not be publicly
+ displayed.
+
+ :param itunes_owner: The owner of the feed.
+ :returns: Data of the owner of the feed.
+ '''
+ if name is not None:
+ if name and email:
+ self.__itunes_owner = {'name': name, 'email': email}
+ elif not name and not email:
+ self.__itunes_owner = None
+ else:
+ raise ValueError('Both name and email have to be set.')
+ return self.__itunes_owner
+
+ def itunes_subtitle(self, itunes_subtitle=None):
+ '''Get or set the itunes:subtitle value for the podcast. The contents
+ of this tag are shown in the Description column in iTunes. The subtitle
+ displays best if it is only a few words long.
+
+ :param itunes_subtitle: Subtitle of the podcast.
+ :returns: Subtitle of the podcast.
+ '''
+ if itunes_subtitle is not None:
+ self.__itunes_subtitle = itunes_subtitle
+ return self.__itunes_subtitle
+
+ def itunes_summary(self, itunes_summary=None):
+ '''Get or set the itunes:summary value for the podcast. The contents of
+ this tag are shown in a separate window that appears when the "circled
+ i" in the Description column is clicked. It also appears on the iTunes
+ page for your podcast. This field can be up to 4000 characters. If
+ `` is not included, the contents of the
+ tag are used.
+
+ :param itunes_summary: Summary of the podcast.
+ :returns: Summary of the podcast.
+ '''
+ if itunes_summary is not None:
+ self.__itunes_summary = itunes_summary
+ return self.__itunes_summary
+
+ def itunes_type(self, itunes_type=None):
+ '''Get or set the itunes:type value of the podcast. This tag should
+ be used to indicate the type of your podcast.
+ The two values for this tag are "episodic" and "serial".
+
+ If your show is Serial you must use this tag.
+
+ Specify episodic when episodes are intended to be consumed without any
+ specific order. Apple Podcasts will present newest episodes first and
+ display the publish date (required) of each episode. If organized into
+ seasons, the newest season will be presented first - otherwise,
+ episodes will be grouped by year published, newest first.
+
+ Specify serial when episodes are intended to be consumed in sequential
+ order. Apple Podcasts will present the oldest episodes first and
+ display the episode numbers (required) of each episode. If organized
+ into seasons, the newest season will be presented first and
+ numbers must be given for each episode.
+
+ :param itunes_type: The type of the podcast
+ :returns: type of the pdocast.
+ '''
+ if itunes_type is not None:
+ if itunes_type not in ('episodic', 'serial'):
+ raise ValueError('Invalid value for type tag')
+ self.__itunes_type = itunes_type
+ return self.__itunes_type
+
+ _itunes_categories = {
+ 'Arts': [
+ 'Design', 'Fashion & Beauty', 'Food', 'Literature',
+ 'Performing Arts', 'Visual Arts'],
+ 'Business': [
+ 'Business News', 'Careers', 'Investing',
+ 'Management & Marketing', 'Shopping'],
+ 'Comedy': [],
+ 'Education': [
+ 'Education', 'Education Technology', 'Higher Education',
+ 'K-12', 'Language Courses', 'Training'],
+ 'Games & Hobbies': [
+ 'Automotive', 'Aviation', 'Hobbies', 'Other Games',
+ 'Video Games'],
+ 'Government & Organizations': [
+ 'Local', 'National', 'Non-Profit', 'Regional'],
+ 'Health': [
+ 'Alternative Health', 'Fitness & Nutrition', 'Self-Help',
+ 'Sexuality'],
+ 'Kids & Family': [],
+ 'Music': [],
+ 'News & Politics': [],
+ 'Religion & Spirituality': [
+ 'Buddhism', 'Christianity', 'Hinduism', 'Islam', 'Judaism',
+ 'Other', 'Spirituality'],
+ 'Science & Medicine': [
+ 'Medicine', 'Natural Sciences', 'Social Sciences'],
+ 'Society & Culture': [
+ 'History', 'Personal Journals', 'Philosophy',
+ 'Places & Travel'],
+ 'Sports & Recreation': [
+ 'Amateur', 'College & High School', 'Outdoor', 'Professional'],
+ 'Technology': [
+ 'Gadgets', 'Tech News', 'Podcasting', 'Software How-To'],
+ 'TV & Film': []}
diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py
index 179694c..2d60c2d 100644
--- a/feedgen/ext/podcast_entry.py
+++ b/feedgen/ext/podcast_entry.py
@@ -1,243 +1,321 @@
# -*- coding: utf-8 -*-
'''
- feedgen.ext.podcast_entry
- ~~~~~~~~~~~~~~~~~~~~~~~~~
+ feedgen.ext.podcast_entry
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
- Extends the feedgen to produce podcasts.
+ Extends the feedgen to produce podcasts.
- :copyright: 2013, Lars Kiesow
+ :copyright: 2013-2016, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
-from lxml import etree
from feedgen.ext.base import BaseEntryExtension
+from feedgen.util import xml_elem
class PodcastEntryExtension(BaseEntryExtension):
- '''FeedEntry extension for podcasts.
- '''
-
-
- def __init__(self):
- ## ITunes tags
- # http://www.apple.com/itunes/podcasts/specs.html#rss
- self.__itunes_author = None
- self.__itunes_block = None
- self.__itunes_image = None
- self.__itunes_duration = None
- self.__itunes_explicit = None
- self.__itunes_is_closed_captioned = None
- self.__itunes_order = None
- self.__itunes_subtitle = None
- self.__itunes_summary = None
-
-
- def extend_rss(self, entry):
- '''Add additional fields to an RSS item.
-
- :param feed: The RSS item XML element to use.
- '''
- ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd'
-
- if self.__itunes_author:
- author = etree.SubElement(entry, '{%s}author' % ITUNES_NS)
- author.text = self.__itunes_author
-
- if not self.__itunes_block is None:
- block = etree.SubElement(entry, '{%s}block' % ITUNES_NS)
- block.text = 'yes' if self.__itunes_block else 'no'
-
- if self.__itunes_image:
- image = etree.SubElement(entry, '{%s}image' % ITUNES_NS)
- image.attrib['href'] = self.__itunes_image
-
- if self.__itunes_duration:
- duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS)
- duration.text = self.__itunes_duration
-
- if self.__itunes_explicit in ('yes', 'no', 'clean'):
- explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS)
- explicit.text = self.__itunes_explicit
-
- if not self.__itunes_is_closed_captioned is None:
- is_closed_captioned = etree.SubElement(entry, '{%s}isClosedCaptioned' % ITUNES_NS)
- is_closed_captioned.text = 'yes' if self.__itunes_is_closed_captioned else 'no'
-
- if not self.__itunes_order is None and self.__itunes_order >= 0:
- order = etree.SubElement(entry, '{%s}order' % ITUNES_NS)
- order.text = str(self.__itunes_order)
-
- if self.__itunes_subtitle:
- subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS)
- subtitle.text = self.__itunes_subtitle
-
- if self.__itunes_summary:
- summary = etree.SubElement(entry, '{%s}summary' % ITUNES_NS)
- summary.text = self.__itunes_summary
- return entry
-
-
- def itunes_author(self, itunes_author=None):
- '''Get or set the itunes:author of the podcast episode. The content of
- this tag is shown in the Artist column in iTunes. If the tag is not
- present, iTunes uses the contents of the tag. If
- is not present at the feed level, iTunes will use the contents of
- .
-
- :param itunes_author: The author of the podcast.
- :returns: The author of the podcast.
- '''
- if not itunes_author is None:
- self.__itunes_author = itunes_author
- return self.__itunes_author
-
-
- def itunes_block(self, itunes_block=None):
- '''Get or set the ITunes block attribute. Use this to prevent episodes
- from appearing in the iTunes podcast directory.
-
- :param itunes_block: Block podcast episodes.
- :returns: If the podcast episode is blocked.
- '''
- if not itunes_block is None:
- self.__itunes_block = itunes_block
- return self.__itunes_block
-
-
- def itunes_image(self, itunes_image=None):
- '''Get or set the image for the podcast episode. This tag specifies the
- artwork for your podcast. Put the URL to the image in the href attribute.
- iTunes prefers square .jpg images that are at least 1400x1400 pixels,
- which is different from what is specified for the standard RSS image tag.
- In order for a podcast to be eligible for an iTunes Store feature, the
- accompanying image must be at least 1400x1400 pixels.
-
- iTunes supports images in JPEG and PNG formats with an RGB color space
- (CMYK is not supported). The URL must end in ".jpg" or ".png". If the
- tag is not present, iTunes will use the contents of the
- RSS image tag.
-
- If you change your podcast’s image, also change the file’s name. iTunes
- may not change the image if it checks your feed and the image URL is the
- same. The server hosting your cover art image must allow HTTP head
- requests for iTS to be able to automatically update your cover art.
-
- :param itunes_image: Image of the podcast.
- :returns: Image of the podcast.
- '''
- if not itunes_image is None:
- if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ):
- ValueError('Image file must be png or jpg')
- self.__itunes_image = itunes_image
- return self.__itunes_image
-
-
- def itunes_duration(self, itunes_duration=None):
- '''Get or set the duration of the podcast episode. The content of this
- tag is shown in the Time column in iTunes.
-
- The tag can be formatted HH:MM:SS, H:MM:SS, MM:SS, or M:SS (H = hours,
- M = minutes, S = seconds). If an integer is provided (no colon present),
- the value is assumed to be in seconds. If one colon is present, the
- number to the left is assumed to be minutes, and the number to the right
- is assumed to be seconds. If more than two colons are present, the
- numbers farthest to the right are ignored.
-
- :param itunes_duration: Duration of the podcast episode.
- :returns: Duration of the podcast episode.
- '''
- if not itunes_duration is None:
- itunes_duration = str(itunes_duration)
- if len(itunes_duration.split(':')) > 3 or \
- itunes_duration.lstrip('0123456789:') != '':
- ValueError('Invalid duration format')
- self.__itunes_duration = itunes_duration
- return self.itunes_duration
-
-
- def itunes_explicit(self, itunes_explicit=None):
- '''Get or the the itunes:explicit value of the podcast episode. This tag
- should be used to indicate whether your podcast episode contains explicit
- material. The three values for this tag are "yes", "no", and "clean".
-
- If you populate this tag with "yes", an "explicit" parental advisory
- graphic will appear next to your podcast artwork on the iTunes Store and
- in the Name column in iTunes. If the value is "clean", the parental
- advisory type is considered Clean, meaning that no explicit language or
- adult content is included anywhere in the episodes, and a "clean" graphic
- will appear. If the explicit tag is present and has any other value
- (e.g., "no"), you see no indicator — blank is the default advisory type.
-
- :param itunes_explicit: If the podcast episode contains explicit material.
- :returns: If the podcast episode contains explicit material.
- '''
- if not itunes_explicit is None:
- if not itunes_explicit in ('', 'yes', 'no', 'clean'):
- raise ValueError('Invalid value for explicit tag')
- self.__itunes_explicit = itunes_explicit
- return self.__itunes_explicit
-
-
- def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None):
- '''Get or set the is_closed_captioned value of the podcast episode. This
- tag should be used if your podcast includes a video episode with embedded
- closed captioning support. The two values for this tag are "yes" and
- "no”.
-
- :param is_closed_captioned: If the episode has closed captioning support.
- :returns: If the episode has closed captioning support.
- '''
- if not itunes_is_closed_captioned is None:
- self.__itunes_is_closed_captioned = itunes_is_closed_captioned in ('yes', True)
- return self.__itunes_is_closed_captioned
-
-
- def itunes_order(self, itunes_order=None):
- '''Get or set the itunes:order value of the podcast episode. This tag can
- be used to override the default ordering of episodes on the store.
-
- This tag is used at an
- level by populating with the number value
- in which you would like the episode to appear on the store. For example,
- if you would like an
- to appear as the first episode in the
- podcast, you would populate the tag with “1”. If
- conflicting order values are present in multiple episodes, the store will
- use default ordering (pubDate).
-
- To remove the order from the episode set the order to a value below zero.
-
- :param itunes_order: The order of the episode.
- :returns: The order of the episode.
- '''
- if not itunes_order is None:
- self.__itunes_order = int(itunes_order)
- return self.__itunes_order
-
-
- def itunes_subtitle(self, itunes_subtitle=None):
- '''Get or set the itunes:subtitle value for the podcast episode. The
- contents of this tag are shown in the Description column in iTunes. The
- subtitle displays best if it is only a few words long.
-
- :param itunes_subtitle: Subtitle of the podcast episode.
- :returns: Subtitle of the podcast episode.
- '''
- if not itunes_subtitle is None:
- self.__itunes_subtitle = itunes_subtitle
- return self.__itunes_subtitle
-
-
- def itunes_summary(self, itunes_summary=None):
- '''Get or set the itunes:summary value for the podcast episode. The
- contents of this tag are shown in a separate window that appears when the
- "circled i" in the Description column is clicked. It also appears on the
- iTunes page for your podcast. This field can be up to 4000 characters. If
- is not included, the contents of the tag
- are used.
-
- :param itunes_summary: Summary of the podcast episode.
- :returns: Summary of the podcast episode.
- '''
- if not itunes_summary is None:
- self.__itunes_summary = itunes_summary
- return self.__itunes_summary
+ '''FeedEntry extension for podcasts.
+ '''
+
+ def __init__(self):
+ # ITunes tags
+ # http://www.apple.com/itunes/podcasts/specs.html#rss
+ self.__itunes_author = None
+ self.__itunes_block = None
+ self.__itunes_image = None
+ self.__itunes_duration = None
+ self.__itunes_explicit = None
+ self.__itunes_is_closed_captioned = None
+ self.__itunes_order = None
+ self.__itunes_subtitle = None
+ self.__itunes_summary = None
+ self.__itunes_season = None
+ self.__itunes_episode = None
+ self.__itunes_title = None
+ self.__itunes_episode_type = None
+
+ def extend_rss(self, entry):
+ '''Add additional fields to an RSS item.
+
+ :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 = xml_elem('{%s}author' % ITUNES_NS, entry)
+ author.text = self.__itunes_author
+
+ if self.__itunes_block is not None:
+ block = xml_elem('{%s}block' % ITUNES_NS, entry)
+ block.text = 'yes' if self.__itunes_block else 'no'
+
+ if self.__itunes_image:
+ image = xml_elem('{%s}image' % ITUNES_NS, entry)
+ image.attrib['href'] = self.__itunes_image
+
+ if self.__itunes_duration:
+ duration = xml_elem('{%s}duration' % ITUNES_NS, entry)
+ duration.text = self.__itunes_duration
+
+ if self.__itunes_explicit in ('yes', 'no', 'clean'):
+ explicit = xml_elem('{%s}explicit' % ITUNES_NS, entry)
+ explicit.text = self.__itunes_explicit
+
+ if self.__itunes_is_closed_captioned is not None:
+ is_closed_captioned = xml_elem(
+ '{%s}isClosedCaptioned' % ITUNES_NS, entry)
+ if self.__itunes_is_closed_captioned:
+ is_closed_captioned.text = 'yes'
+ else:
+ is_closed_captioned.text = 'no'
+
+ if self.__itunes_order is not None and self.__itunes_order >= 0:
+ order = xml_elem('{%s}order' % ITUNES_NS, entry)
+ order.text = str(self.__itunes_order)
+
+ if self.__itunes_subtitle:
+ subtitle = xml_elem('{%s}subtitle' % ITUNES_NS, entry)
+ subtitle.text = self.__itunes_subtitle
+
+ if self.__itunes_summary:
+ summary = xml_elem('{%s}summary' % ITUNES_NS, entry)
+ summary.text = self.__itunes_summary
+
+ if self.__itunes_season:
+ season = xml_elem('{%s}season' % ITUNES_NS, entry)
+ season.text = str(self.__itunes_season)
+
+ if self.__itunes_episode:
+ episode = xml_elem('{%s}episode' % ITUNES_NS, entry)
+ episode.text = str(self.__itunes_episode)
+
+ if self.__itunes_title:
+ title = xml_elem('{%s}title' % ITUNES_NS, entry)
+ title.text = self.__itunes_title
+
+ if self.__itunes_episode_type in ('full', 'trailer', 'bonus'):
+ episode_type = xml_elem('{%s}episodeType' % ITUNES_NS, entry)
+ episode_type.text = self.__itunes_episode_type
+ return entry
+
+ def itunes_author(self, itunes_author=None):
+ '''Get or set the itunes:author of the podcast episode. The content of
+ this tag is shown in the Artist column in iTunes. If the tag is not
+ present, iTunes uses the contents of the tag. If
+ is not present at the feed level, iTunes will use the
+ contents of .
+
+ :param itunes_author: The author of the podcast.
+ :returns: The author of the podcast.
+ '''
+ if itunes_author is not None:
+ self.__itunes_author = itunes_author
+ return self.__itunes_author
+
+ def itunes_block(self, itunes_block=None):
+ '''Get or set the ITunes block attribute. Use this to prevent episodes
+ from appearing in the iTunes podcast directory.
+
+ :param itunes_block: Block podcast episodes.
+ :returns: If the podcast episode is blocked.
+ '''
+ if itunes_block is not None:
+ self.__itunes_block = itunes_block
+ return self.__itunes_block
+
+ def itunes_image(self, itunes_image=None):
+ '''Get or set the image for the podcast episode. This tag specifies the
+ artwork for your podcast. Put the URL to the image in the href
+ attribute. iTunes prefers square .jpg images that are at least
+ 1400x1400 pixels, which is different from what is specified for the
+ standard RSS image tag. In order for a podcast to be eligible for an
+ iTunes Store feature, the accompanying image must be at least 1400x1400
+ pixels.
+
+ iTunes supports images in JPEG and PNG formats with an RGB color space
+ (CMYK is not supported). The URL must end in ".jpg" or ".png". If the
+ tag is not present, iTunes will use the contents of the
+ RSS image tag.
+
+ If you change your podcast’s image, also change the file’s name. iTunes
+ may not change the image if it checks your feed and the image URL is
+ the same. The server hosting your cover art image must allow HTTP head
+ requests for iTS to be able to automatically update your cover art.
+
+ :param itunes_image: Image of the podcast.
+ :returns: Image of the podcast.
+ '''
+ if itunes_image is not None:
+ if itunes_image.endswith('.jpg') or itunes_image.endswith('.png'):
+ self.__itunes_image = itunes_image
+ else:
+ raise ValueError('Image file must be png or jpg')
+ return self.__itunes_image
+
+ def itunes_duration(self, itunes_duration=None):
+ '''Get or set the duration of the podcast episode. The content of this
+ tag is shown in the Time column in iTunes.
+
+ The tag can be formatted HH:MM:SS, H:MM:SS, MM:SS, or M:SS (H = hours,
+ M = minutes, S = seconds). If an integer is provided (no colon
+ present), the value is assumed to be in seconds. If one colon is
+ present, the number to the left is assumed to be minutes, and the
+ number to the right is assumed to be seconds. If more than two colons
+ are present, the numbers farthest to the right are ignored.
+
+ :param itunes_duration: Duration of the podcast episode.
+ :returns: Duration of the podcast episode.
+ '''
+ if itunes_duration is not None:
+ itunes_duration = str(itunes_duration)
+ if len(itunes_duration.split(':')) > 3 or \
+ itunes_duration.lstrip('0123456789:') != '':
+ raise ValueError('Invalid duration format')
+ self.__itunes_duration = itunes_duration
+ return self.__itunes_duration
+
+ def itunes_explicit(self, itunes_explicit=None):
+ '''Get or the the itunes:explicit value of the podcast episode. This
+ tag should be used to indicate whether your podcast episode contains
+ explicit material. The three values for this tag are "yes", "no", and
+ "clean".
+
+ If you populate this tag with "yes", an "explicit" parental advisory
+ graphic will appear next to your podcast artwork on the iTunes Store
+ and in the Name column in iTunes. If the value is "clean", the parental
+ advisory type is considered Clean, meaning that no explicit language or
+ adult content is included anywhere in the episodes, and a "clean"
+ graphic will appear. If the explicit tag is present and has any other
+ value (e.g., "no"), you see no indicator — blank is the default
+ advisory type.
+
+ :param itunes_explicit: If the podcast episode contains explicit
+ material.
+ :returns: If the podcast episode contains explicit material.
+ '''
+ if itunes_explicit is not None:
+ if itunes_explicit not in ('', 'yes', 'no', 'clean'):
+ raise ValueError('Invalid value for explicit tag')
+ self.__itunes_explicit = itunes_explicit
+ return self.__itunes_explicit
+
+ def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None):
+ '''Get or set the is_closed_captioned value of the podcast episode.
+ This tag should be used if your podcast includes a video episode with
+ embedded closed captioning support. The two values for this tag are
+ "yes" and "no”.
+
+ :param is_closed_captioned: If the episode has closed captioning
+ support.
+ :returns: If the episode has closed captioning support.
+ '''
+ if itunes_is_closed_captioned is not None:
+ self.__itunes_is_closed_captioned = \
+ itunes_is_closed_captioned in ('yes', True)
+ return self.__itunes_is_closed_captioned
+
+ def itunes_order(self, itunes_order=None):
+ '''Get or set the itunes:order value of the podcast episode. This tag
+ can be used to override the default ordering of episodes on the store.
+
+ This tag is used at an
- level by populating with the number value
+ in which you would like the episode to appear on the store. For
+ example, if you would like an
- to appear as the first episode in
+ the podcast, you would populate the tag with “1”. If
+ conflicting order values are present in multiple episodes, the store
+ will use default ordering (pubDate).
+
+ To remove the order from the episode set the order to a value below
+ zero.
+
+ :param itunes_order: The order of the episode.
+ :returns: The order of the episode.
+ '''
+ if itunes_order is not None:
+ self.__itunes_order = int(itunes_order)
+ return self.__itunes_order
+
+ def itunes_subtitle(self, itunes_subtitle=None):
+ '''Get or set the itunes:subtitle value for the podcast episode. The
+ contents of this tag are shown in the Description column in iTunes. The
+ subtitle displays best if it is only a few words long.
+
+ :param itunes_subtitle: Subtitle of the podcast episode.
+ :returns: Subtitle of the podcast episode.
+ '''
+ if itunes_subtitle is not None:
+ self.__itunes_subtitle = itunes_subtitle
+ return self.__itunes_subtitle
+
+ def itunes_summary(self, itunes_summary=None):
+ '''Get or set the itunes:summary value for the podcast episode. The
+ contents of this tag are shown in a separate window that appears when
+ the "circled i" in the Description column is clicked. It also appears
+ on the iTunes page for your podcast. This field can be up to 4000
+ characters. If is not included, the contents of the
+ tag are used.
+
+ :param itunes_summary: Summary of the podcast episode.
+ :returns: Summary of the podcast episode.
+ '''
+ if itunes_summary is not None:
+ self.__itunes_summary = itunes_summary
+ return self.__itunes_summary
+
+ def itunes_season(self, itunes_season=None):
+ '''Get or set the itunes:season value for the podcast episode.
+
+ :param itunes_season: Season number of the podcast epiosode.
+ :returns: Season number of the podcast episode.
+ '''
+ if itunes_season is not None:
+ self.__itunes_season = int(itunes_season)
+ return self.__itunes_season
+
+ def itunes_episode(self, itunes_episode=None):
+ '''Get or set the itunes:episode value for the podcast episode.
+
+ :param itunes_season: Episode number of the podcast epiosode.
+ :returns: Episode number of the podcast episode.
+ '''
+ if itunes_episode is not None:
+ self.__itunes_episode = int(itunes_episode)
+ return self.__itunes_episode
+
+ def itunes_title(self, itunes_title=None):
+ '''Get or set the itunes:title value for the podcast episode.
+
+ An episode title specific for Apple Podcasts. Don’t specify the episode
+ number or season number in this tag. Also, don’t repeat the title of
+ your show within your episode title.
+
+ :param itunes_title: Episode title specific for Apple Podcasts
+ :returns: Title specific for Apple Podcast
+ '''
+ if itunes_title is not None:
+ self.__itunes_title = itunes_title
+ return self.__itunes_title
+
+ def itunes_episode_type(self, itunes_episode_type=None):
+ '''Get or set the itunes:episodeType value of the item. This tag should
+ be used to indicate the episode type.
+ The three values for this tag are "full", "trailer" and "bonus".
+
+ If an episode is a trailer or bonus content, use this tag.
+
+ Specify full when you are submitting the complete content of your show.
+ Specify trailer when you are submitting a short, promotional piece of
+ content that represents a preview of your current show.
+ Specify bonus when you are submitting extra content for your show (for
+ example, behind the scenes information or interviews with the cast) or
+ cross-promotional content for another show.
+
+ :param itunes_episode_type: The episode type
+ :returns: type of the episode.
+ '''
+ if itunes_episode_type is not None:
+ if itunes_episode_type not in ('full', 'trailer', 'bonus'):
+ raise ValueError('Invalid value for episodeType tag')
+ self.__itunes_episode_type = itunes_episode_type
+ return self.__itunes_episode_type
diff --git a/feedgen/ext/syndication.py b/feedgen/ext/syndication.py
new file mode 100644
index 0000000..016b144
--- /dev/null
+++ b/feedgen/ext/syndication.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright 2015 Kenichi Sato
+#
+
+'''
+Extends FeedGenerator to support Syndication module
+
+See below for details
+http://web.resource.org/rss/1.0/modules/syndication/
+'''
+
+from feedgen.ext.base import BaseExtension
+from feedgen.util import xml_elem
+
+SYNDICATION_NS = 'http://purl.org/rss/1.0/modules/syndication/'
+PERIOD_TYPE = ('hourly', 'daily', 'weekly', 'monthly', 'yearly')
+
+
+def _set_value(channel, name, value):
+ if value:
+ newelem = xml_elem('{%s}' % SYNDICATION_NS + name, channel)
+ 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/ext/torrent.py b/feedgen/ext/torrent.py
new file mode 100644
index 0000000..5548a81
--- /dev/null
+++ b/feedgen/ext/torrent.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+'''
+ feedgen.ext.torrent
+ ~~~~~~~~~~~~~~~~~~~
+
+ Extends the FeedGenerator to produce torrent feeds.
+
+ :copyright: 2016, Raspbeguy
+
+ :license: FreeBSD and LGPL, see license.* for more details.
+'''
+
+from feedgen.ext.base import BaseEntryExtension, BaseExtension
+from feedgen.util import xml_elem
+
+TORRENT_NS = 'http://xmlns.ezrss.it/0.1/dtd/'
+
+
+class TorrentExtension(BaseExtension):
+ '''FeedGenerator extension for torrent feeds.
+ '''
+ def extend_ns(self):
+ return {'torrent': TORRENT_NS}
+
+
+class TorrentEntryExtension(BaseEntryExtension):
+ '''FeedEntry extension for torrent feeds
+ '''
+ def __init__(self):
+ self.__torrent_filename = None
+ self.__torrent_infohash = None
+ self.__torrent_contentlength = None
+ self.__torrent_seeds = None
+ self.__torrent_peers = None
+ self.__torrent_verified = None
+
+ def extend_rss(self, entry):
+ '''Add additional fields to an RSS item.
+
+ :param feed: The RSS item XML element to use.
+ '''
+ if self.__torrent_filename:
+ filename = xml_elem('{%s}filename' % TORRENT_NS, entry)
+ filename.text = self.__torrent_filename
+
+ if self.__torrent_contentlength:
+ contentlength = xml_elem('{%s}contentlength' % TORRENT_NS, entry)
+ contentlength.text = self.__torrent_contentlength
+
+ if self.__torrent_infohash:
+ infohash = xml_elem('{%s}infohash' % TORRENT_NS, entry)
+ infohash.text = self.__torrent_infohash
+ magnet = xml_elem('{%s}magneturi' % TORRENT_NS, entry)
+ magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_infohash
+
+ if self.__torrent_seeds:
+ seeds = xml_elem('{%s}seed' % TORRENT_NS, entry)
+ seeds.text = self.__torrent_seeds
+
+ if self.__torrent_peers:
+ peers = xml_elem('{%s}peers' % TORRENT_NS, entry)
+ peers.text = self.__torrent_peers
+
+ if self.__torrent_verified:
+ verified = xml_elem('{%s}verified' % TORRENT_NS, entry)
+ verified.text = self.__torrent_verified
+
+ def filename(self, torrent_filename=None):
+ '''Get or set the name of the torrent file.
+
+ :param torrent_filename: The name of the torrent file.
+ :returns: The name of the torrent file.
+ '''
+ if torrent_filename is not None:
+ self.__torrent_filename = torrent_filename
+ return self.__torrent_filename
+
+ def infohash(self, torrent_infohash=None):
+ '''Get or set the hash of the target file.
+
+ :param torrent_infohash: The target file hash.
+ :returns: The target hash file.
+ '''
+ if torrent_infohash is not None:
+ self.__torrent_infohash = torrent_infohash
+ return self.__torrent_infohash
+
+ def contentlength(self, torrent_contentlength=None):
+ '''Get or set the size of the target file.
+
+ :param torrent_contentlength: The target file size.
+ :returns: The target file size.
+ '''
+ if torrent_contentlength is not None:
+ self.__torrent_contentlength = torrent_contentlength
+ return self.__torrent_contentlength
+
+ def seeds(self, torrent_seeds=None):
+ '''Get or set the number of seeds.
+
+ :param torrent_seeds: The seeds number.
+ :returns: The seeds number.
+ '''
+ if torrent_seeds is not None:
+ self.__torrent_seeds = torrent_seeds
+ return self.__torrent_seeds
+
+ def peers(self, torrent_peers=None):
+ '''Get or set the number od peers
+
+ :param torrent_infohash: The peers number.
+ :returns: The peers number.
+ '''
+ if torrent_peers is not None:
+ self.__torrent_peers = torrent_peers
+ return self.__torrent_peers
+
+ def verified(self, torrent_verified=None):
+ '''Get or set the number of verified peers.
+
+ :param torrent_infohash: The verified peers number.
+ :returns: The verified peers number.
+ '''
+ if torrent_verified is not None:
+ self.__torrent_verified = torrent_verified
+ return self.__torrent_verified
diff --git a/feedgen/feed.py b/feedgen/feed.py
index 315d109..0450304 100644
--- a/feedgen/feed.py
+++ b/feedgen/feed.py
@@ -1,1093 +1,1176 @@
# -*- coding: utf-8 -*-
'''
- feedgen.feed
- ~~~~~~~~~~~~
+ feedgen.feed
+ ~~~~~~~~~~~~
- :copyright: 2013, Lars Kiesow
+ :copyright: 2013-2020, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
-from lxml import etree
+import sys
from datetime import datetime
+
import dateutil.parser
import dateutil.tz
-from feedgen.entry import FeedEntry
-from feedgen.util import ensure_format
-import feedgen.version
-import sys
+from lxml import etree # nosec - not using this for parsing
+import feedgen.version
+from feedgen.compat import string_types
+from feedgen.entry import FeedEntry
+from feedgen.util import ensure_format, formatRFC2822, xml_elem
_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*, schema, 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('schema'):
- cat.attrib['schema'] = c['schema']
- 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):
- '''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).
- :returns: String representation of the ATOM feed.
- '''
- feed, doc = self._create_atom(extensions=extensions)
- return etree.tostring(feed, pretty_print=pretty)
-
-
- def atom_file(self, filename, extensions=True, pretty=False):
- '''Generates an ATOM feed and write the resulting XML to a file.
-
- :param filename: Name of file to write, or a file-like object, or a URL.
- :param extensions: Enable or disable the loaded extensions for the xml
- generation (default: enabled).
- '''
- feed, doc = self._create_atom(extensions=extensions)
- doc.write(filename, pretty_print=pretty)
-
-
- 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 = self.__rss_lastBuildDate.strftime(
- '%a, %d %b %Y %H:%M:%S %z')
- if self.__rss_managingEditor:
- managingEditor = etree.SubElement(channel, 'managingEditor')
- managingEditor.text = self.__rss_managingEditor
- if self.__rss_pubDate:
- pubDate = etree.SubElement(channel, 'pubDate')
- pubDate.text = self.__rss_pubDate.strftime(
- '%a, %d %b %Y %H:%M:%S %z')
- 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 = 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):
- '''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).
- :returns: String representation of the RSS feed.
- '''
- feed, doc = self._create_rss(extensions=extensions)
- return etree.tostring(feed, pretty_print=pretty)
-
-
- def rss_file(self, filename, extensions=True, pretty=False):
- '''Generates an RSS feed and write the resulting XML to a file.
-
- :param filename: Name of file to write, or a file-like object, or a URL.
- :param extensions: Enable or disable the loaded extensions for the xml
- generation (default: enabled).
- '''
- feed, doc = self._create_rss(extensions=extensions)
- doc.write(filename, pretty_print=pretty)
-
-
- 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, basestring):
- 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':['alternate', 'enclosure', 'related', 'self', 'via']} )
- # 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:schema is the
- # rss:domain.
- self.__rss_category = []
- for cat in self.__atom_category:
- rss_cat = {}
- rss_cat['value'] = cat['label'] if cat.get('label') else cat['term']
- if cat.get('schema'):
- rss_cat['domain'] = cat['schema']
- 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 = {'donain':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 in None:
- self.__atom_generator['version'] = version
- if not uri in 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, basestring):
- 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 xrange(24):
- raise ValueError('Invalid hour %s' % h)
- if replace or not self.__rss_skipHours:
- self.__rss_skipHours = set()
- self.__rss_skipHours |= set(hours)
- return self.__rss_skipHours
-
-
- def skipDays(self, days=None, replace=False):
- '''Set or get the value of skipDays, a hint for aggregators telling them
- which days they can skip This is an RSS only value.
-
- This method can be called with a day name or a list of day names. The days are
- represented as strings from 'Monday' to 'Sunday'.
-
- :param hours: List of days the feedreaders should not check the feed.
- :param replace: Add or replace old data.
- :returns: List of days the feedreaders should not check the feed.
- '''
- if not days is None:
- if not (isinstance(days, list) or isinstance(days, set)):
- days = [days]
- for d in days:
- if not d in ['Monday', 'Tuesday', 'Wednesday', 'Thursday',
- 'Friday', 'Saturday', 'Sunday']:
- raise ValueError('Invalid day %s' % h)
- if replace or not self.__rss_skipDays:
- self.__rss_skipDays = set()
- self.__rss_skipDays |= set(days)
- return self.__rss_skipDays
-
-
- def textInput(self, title=None, description=None, name=None, link=None):
- '''Get or set the value of textInput. This is an RSS only field. The
- purpose of the element is something of a mystery. You can use
- it to specify a search engine box. Or to allow a reader to provide
- feedback. Most aggregators ignore it.
-
- :param title: The label of the Submit button in the text input area.
- :param description: Explains the text input area.
- :param name: The name of the text object in the text input area.
- :param link: The URL of the CGI script that processes text input requests.
- :returns: Dictionary containing textInput values.
- '''
- if not title is None:
- self.__rss_textInput = {}
- self.__rss_textInput['title'] = title
- self.__rss_textInput['description'] = description
- self.__rss_textInput['name'] = name
- self.__rss_textInput['link'] = link
- return self.__rss_textInput
-
-
- def ttl(self, ttl=None):
- '''Get or set the ttl value. It is an RSS only element. ttl stands for
- time to live. It's a number of minutes that indicates how long a channel
- can be cached before refreshing from the source.
-
- :param ttl: Integer value indicating how long the channel may be cached.
- :returns: Time to live.
- '''
- if not ttl is None:
- self.__rss_ttl = int(ttl)
- return self.__rss_ttl
-
-
- def webMaster(self, webMaster=None):
- '''Get and set the value of webMaster, which represents the email address
- for the person responsible for technical issues relating to the feed.
- This is an RSS only value.
-
- :param webMaster: Email address of the webmaster.
- :returns: Email address of the webmaster.
- '''
- if not webMaster is None:
- self.__rss_webMaster = webMaster
- return self.__rss_webMaster
-
-
- def add_entry(self, feedEntry=None):
- '''This method will add a new entry to the feed. If the feedEntry
- argument is omittet a new Entry object is created automatically. This is
- the prefered way to add new entries to a feed.
-
- :param feedEntry: FeedEntry object to add.
- :returns: FeedEntry object created or passed to this function.
-
- Example::
-
- ...
- >>> entry = feedgen.add_entry()
- >>> entry.title('First feed entry')
-
- '''
- if feedEntry is None:
- feedEntry = FeedEntry()
-
- version = sys.version_info[0]
-
- if version == 2:
- items = self.__extensions.iteritems()
- else:
- items = self.__extensions.items()
-
- # Try to load extensions:
- for extname,ext in items:
- try:
- feedEntry.load_extension( extname, ext['atom'], ext['rss'] )
- except ImportError:
- pass
-
- self.__feed_entries.append( feedEntry )
- return feedEntry
-
-
- def add_item(self, item=None):
- '''This method will add a new item to the feed. If the item argument is
- omittet a new FeedEntry object is created automatically. This is just
- another name for add_entry(...)
- '''
- return self.add_entry(item)
-
-
- def entry(self, entry=None, replace=False):
- '''Get or set feed entries. Use the add_entry() method instead to
- automatically create the FeedEntry objects.
-
- This method takes both a single FeedEntry object or a list of objects.
-
- :param entry: FeedEntry object or list of FeedEntry objects.
- :returns: List ob all feed entries.
- '''
- if not entry is None:
- if not isinstance(entry, list):
- entry = [entry]
- if replace:
- self.__feed_entries = []
-
- version = sys.version_info[0]
-
- if version == 2:
- items = self.__extensions.iteritems()
- else:
- items = self.__extensions.items()
-
- # Try to load extensions:
- for e in entry:
- for extname,ext in items:
- try:
- e.load_extension( extname, ext['atom'], ext['rss'] )
- except ImportError:
- pass
-
- self.__feed_entries += entry
- return self.__feed_entries
-
-
- def item(self, item=None, replace=False):
- '''Get or set feed items. This is just another name for entry(...)
- '''
- return self.entry(item, replace)
-
-
- def remove_entry(self, entry):
- '''Remove a single entry from the feed. This method accepts both the
- FeedEntry object to remove or the index of the entry as argument.
-
- :param entry: Entry or index of entry to remove.
- '''
- if isinstance(entry, FeedEntry):
- self.__feed_entries.remove(entry)
- else:
- self.__feed_entries.pop(entry)
-
-
- def remove_item(self, item):
- '''Remove a single item from the feed. This is another name for
- remove_entry.
- '''
- self.remove_entry(item)
-
-
- def load_extension(self, name, atom=True, rss=True):
- '''Load a specific extension by name.
-
- :param name: Name of the extension to load.
- :param atom: If the extension should be used for ATOM feeds.
- :param rss: If the extension should be used for RSS feeds.
- '''
- # Check loaded extensions
- if not isinstance(self.__extensions, dict):
- self.__extensions = {}
- if name in self.__extensions.keys():
- raise ImportError('Extension already loaded')
-
- # Load extension
- extname = name[0].upper() + name[1:] + 'Extension'
- supmod = __import__('feedgen.ext.%s' % name)
- extmod = getattr(supmod.ext, name)
- ext = getattr(extmod, extname)
- extinst = ext()
- setattr(self, name, extinst)
- self.__extensions[name] = {'inst':extinst,'atom':atom,'rss':rss}
-
- # Try to load the extension for already existing entries:
- for entry in self.__feed_entries:
- try:
- entry.load_extension( name, atom, rss )
- except ImportError:
- pass
+ '''FeedGenerator for generating ATOM and RSS feeds.
+ '''
+
+ def __init__(self):
+ self.__feed_entries = []
+
+ # ATOM
+ # https://tools.ietf.org/html/rfc4287
+ # required
+ self.__atom_id = None
+ self.__atom_title = None
+ self.__atom_updated = datetime.now(dateutil.tz.tzutc())
+
+ # recommended
+ self.__atom_author = None # {name*, uri, email}
+ self.__atom_link = None # {href*, rel, type, hreflang, title, length}
+
+ # optional
+ self.__atom_category = None # {term*, scheme, label}
+ self.__atom_contributor = None
+ self.__atom_generator = {
+ 'value': 'python-feedgen',
+ 'uri': 'https://lkiesow.github.io/python-feedgen',
+ 'version': feedgen.version.version_str} # {value*,uri,version}
+ self.__atom_icon = None
+ self.__atom_logo = None
+ self.__atom_rights = None
+ self.__atom_subtitle = None
+
+ # other
+ self.__atom_feed_xml_lang = None
+
+ # RSS
+ # http://www.rssboard.org/rss-specification
+ self.__rss_title = None
+ self.__rss_link = None
+ self.__rss_description = None
+
+ self.__rss_category = None
+ self.__rss_cloud = None
+ self.__rss_copyright = None
+ self.__rss_docs = 'http://www.rssboard.org/rss-specification'
+ self.__rss_generator = 'python-feedgen'
+ self.__rss_image = None
+ self.__rss_language = None
+ self.__rss_lastBuildDate = datetime.now(dateutil.tz.tzutc())
+ self.__rss_managingEditor = None
+ self.__rss_pubDate = None
+ self.__rss_rating = None
+ self.__rss_skipHours = None
+ self.__rss_skipDays = None
+ self.__rss_textInput = None
+ self.__rss_ttl = None
+ self.__rss_webMaster = None
+
+ # Extension list:
+ self.__extensions = {}
+
+ def _create_atom(self, extensions=True):
+ '''Create a ATOM feed xml structure containing all previously set
+ fields.
+
+ :returns: Tuple containing the feed root element and the element tree.
+ '''
+ nsmap = dict()
+ if extensions:
+ for ext in self.__extensions.values() or []:
+ if ext.get('atom'):
+ nsmap.update(ext['inst'].extend_ns())
+
+ feed = xml_elem('feed',
+ xmlns='http://www.w3.org/2005/Atom',
+ nsmap=nsmap)
+ if self.__atom_feed_xml_lang:
+ feed.attrib['{http://www.w3.org/XML/1998/namespace}lang'] = \
+ self.__atom_feed_xml_lang
+
+ if not (self.__atom_id and self.__atom_title and self.__atom_updated):
+ missing = ([] if self.__atom_title else ['title']) + \
+ ([] if self.__atom_id else ['id']) + \
+ ([] if self.__atom_updated else ['updated'])
+ missing = ', '.join(missing)
+ raise ValueError('Required fields not set (%s)' % missing)
+ id = xml_elem('id', feed)
+ id.text = self.__atom_id
+ title = xml_elem('title', feed)
+ title.text = self.__atom_title
+ updated = xml_elem('updated', feed)
+ 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 = xml_elem('author', feed)
+ name = xml_elem('name', author)
+ name.text = a.get('name')
+ if a.get('email'):
+ email = xml_elem('email', author)
+ email.text = a.get('email')
+ if a.get('uri'):
+ uri = xml_elem('uri', author)
+ uri.text = a.get('uri')
+
+ for ln in self.__atom_link or []:
+ link = xml_elem('link', feed, href=ln['href'])
+ if ln.get('rel'):
+ link.attrib['rel'] = ln['rel']
+ if ln.get('type'):
+ link.attrib['type'] = ln['type']
+ if ln.get('hreflang'):
+ link.attrib['hreflang'] = ln['hreflang']
+ if ln.get('title'):
+ link.attrib['title'] = ln['title']
+ if ln.get('length'):
+ link.attrib['length'] = ln['length']
+
+ for c in self.__atom_category or []:
+ cat = xml_elem('category', feed, term=c['term'])
+ 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 = xml_elem('contributor', feed)
+ name = xml_elem('name', contrib)
+ name.text = c.get('name')
+ if c.get('email'):
+ email = xml_elem('email', contrib)
+ email.text = c.get('email')
+ if c.get('uri'):
+ uri = xml_elem('uri', contrib)
+ uri.text = c.get('uri')
+
+ if self.__atom_generator and self.__atom_generator.get('value'):
+ generator = xml_elem('generator', feed)
+ 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 = xml_elem('icon', feed)
+ icon.text = self.__atom_icon
+
+ if self.__atom_logo:
+ logo = xml_elem('logo', feed)
+ logo.text = self.__atom_logo
+
+ if self.__atom_rights:
+ rights = xml_elem('rights', feed)
+ rights.text = self.__atom_rights
+
+ if self.__atom_subtitle:
+ subtitle = xml_elem('subtitle', feed)
+ subtitle.text = self.__atom_subtitle
+
+ if extensions:
+ for ext in self.__extensions.values() or []:
+ if ext.get('atom'):
+ ext['inst'].extend_atom(feed)
+
+ for entry in self.__feed_entries:
+ entry = entry.atom_entry()
+ feed.append(entry)
+
+ doc = etree.ElementTree(feed)
+ return feed, doc
+
+ def atom_str(self, pretty=False, extensions=True, encoding='UTF-8',
+ xml_declaration=True):
+ '''Generates an ATOM feed and returns the feed XML as string.
+
+ :param pretty: If the feed should be split into multiple lines and
+ properly indented.
+ :param extensions: Enable or disable the loaded extensions for the xml
+ generation (default: enabled).
+ :param encoding: Encoding used in the XML file (default: UTF-8).
+ :param xml_declaration: If an XML declaration should be added to the
+ output (Default: enabled).
+ :returns: String representation of the ATOM feed.
+
+ **Return type:** The return type may vary between different Python
+ versions and your encoding parameters passed to this method. For
+ details have a look at the `lxml documentation
+ `_
+ '''
+ feed, doc = self._create_atom(extensions=extensions)
+ return etree.tostring(doc, 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 = xml_elem('rss', version='2.0', nsmap=nsmap)
+ channel = xml_elem('channel', feed)
+ if not (self.__rss_title and
+ self.__rss_link and
+ self.__rss_description):
+ missing = ([] if self.__rss_title else ['title']) + \
+ ([] if self.__rss_link else ['link']) + \
+ ([] if self.__rss_description else ['description'])
+ missing = ', '.join(missing)
+ raise ValueError('Required fields not set (%s)' % missing)
+ title = xml_elem('title', channel)
+ title.text = self.__rss_title
+ link = xml_elem('link', channel)
+ link.text = self.__rss_link
+ desc = xml_elem('description', channel)
+ desc.text = self.__rss_description
+ for ln in self.__atom_link or []:
+ # It is recommended to include a atom self link in rss documents…
+ if ln.get('rel') == 'self':
+ selflink = xml_elem('{http://www.w3.org/2005/Atom}link',
+ channel, href=ln['href'], rel='self')
+ if ln.get('type'):
+ selflink.attrib['type'] = ln['type']
+ if ln.get('hreflang'):
+ 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 = xml_elem('category', channel)
+ category.text = cat['value']
+ if cat.get('domain'):
+ category.attrib['domain'] = cat['domain']
+ if self.__rss_cloud:
+ cloud = xml_elem('cloud', channel)
+ cloud.attrib['domain'] = self.__rss_cloud.get('domain')
+ cloud.attrib['port'] = self.__rss_cloud.get('port')
+ cloud.attrib['path'] = self.__rss_cloud.get('path')
+ cloud.attrib['registerProcedure'] = self.__rss_cloud.get(
+ 'registerProcedure')
+ cloud.attrib['protocol'] = self.__rss_cloud.get('protocol')
+ if self.__rss_copyright:
+ copyright = xml_elem('copyright', channel)
+ copyright.text = self.__rss_copyright
+ if self.__rss_docs:
+ docs = xml_elem('docs', channel)
+ docs.text = self.__rss_docs
+ if self.__rss_generator:
+ generator = xml_elem('generator', channel)
+ generator.text = self.__rss_generator
+ if self.__rss_image:
+ image = xml_elem('image', channel)
+ url = xml_elem('url', image)
+ url.text = self.__rss_image.get('url')
+ title = xml_elem('title', image)
+ title.text = self.__rss_image.get('title', self.__rss_title)
+ link = xml_elem('link', image)
+ link.text = self.__rss_image.get('link', self.__rss_link)
+ if self.__rss_image.get('width'):
+ width = xml_elem('width', image)
+ width.text = self.__rss_image.get('width')
+ if self.__rss_image.get('height'):
+ height = xml_elem('height', image)
+ height.text = self.__rss_image.get('height')
+ if self.__rss_image.get('description'):
+ description = xml_elem('description', image)
+ description.text = self.__rss_image.get('description')
+ if self.__rss_language:
+ language = xml_elem('language', channel)
+ language.text = self.__rss_language
+ if self.__rss_lastBuildDate:
+ lastBuildDate = xml_elem('lastBuildDate', channel)
+
+ lastBuildDate.text = formatRFC2822(self.__rss_lastBuildDate)
+ if self.__rss_managingEditor:
+ managingEditor = xml_elem('managingEditor', channel)
+ managingEditor.text = self.__rss_managingEditor
+ if self.__rss_pubDate:
+ pubDate = xml_elem('pubDate', channel)
+ pubDate.text = formatRFC2822(self.__rss_pubDate)
+ if self.__rss_rating:
+ rating = xml_elem('rating', channel)
+ rating.text = self.__rss_rating
+ if self.__rss_skipHours:
+ skipHours = xml_elem('skipHours', channel)
+ for h in self.__rss_skipHours:
+ hour = xml_elem('hour', skipHours)
+ hour.text = str(h)
+ if self.__rss_skipDays:
+ skipDays = xml_elem('skipDays', channel)
+ for d in self.__rss_skipDays:
+ day = xml_elem('day', skipDays)
+ day.text = d
+ if self.__rss_textInput:
+ textInput = xml_elem('textInput', channel)
+ textInput.attrib['title'] = self.__rss_textInput.get('title')
+ textInput.attrib['description'] = \
+ self.__rss_textInput.get('description')
+ textInput.attrib['name'] = self.__rss_textInput.get('name')
+ textInput.attrib['link'] = self.__rss_textInput.get('link')
+ if self.__rss_ttl:
+ ttl = xml_elem('ttl', channel)
+ ttl.text = str(self.__rss_ttl)
+ if self.__rss_webMaster:
+ webMaster = xml_elem('webMaster', channel)
+ webMaster.text = self.__rss_webMaster
+
+ if extensions:
+ for ext in self.__extensions.values() or []:
+ if ext.get('rss'):
+ ext['inst'].extend_rss(feed)
+
+ for entry in self.__feed_entries:
+ item = entry.rss_entry()
+ channel.append(item)
+
+ doc = etree.ElementTree(feed)
+ return feed, doc
+
+ def rss_str(self, pretty=False, extensions=True, encoding='UTF-8',
+ xml_declaration=True):
+ '''Generates an RSS feed and returns the feed XML as string.
+
+ :param pretty: If the feed should be split into multiple lines and
+ properly indented.
+ :param extensions: Enable or disable the loaded extensions for the xml
+ generation (default: enabled).
+ :param encoding: Encoding used in the XML file (default: UTF-8).
+ :param xml_declaration: If an XML declaration should be added to the
+ output (Default: enabled).
+ :returns: String representation of the RSS feed.
+
+ **Return type:** The return type may vary between different Python
+ versions and your encoding parameters passed to this method. For
+ details have a look at the `lxml documentation
+ `_
+ '''
+ feed, doc = self._create_rss(extensions=extensions)
+ return etree.tostring(doc, pretty_print=pretty, encoding=encoding,
+ xml_declaration=xml_declaration)
+
+ def rss_file(self, filename, extensions=True, pretty=False,
+ encoding='UTF-8', xml_declaration=True):
+ '''Generates an RSS feed and write the resulting XML to a file.
+
+ :param filename: Name of file to write or a file-like object or a URL.
+ :param extensions: Enable or disable the loaded extensions for the xml
+ generation (default: enabled).
+ :param pretty: If the feed should be split into multiple lines and
+ properly indented.
+ :param encoding: Encoding used in the XML file (default: UTF-8).
+ :param xml_declaration: If an XML declaration should be added to the
+ output (Default: enabled).
+ '''
+ feed, doc = self._create_rss(extensions=extensions)
+ doc.write(filename, pretty_print=pretty, encoding=encoding,
+ xml_declaration=xml_declaration)
+
+ def title(self, title=None):
+ '''Get or set the title value of the feed. It should contain a human
+ readable title for the feed. Often the same as the title of the
+ associated website. Title is mandatory for both ATOM and RSS and should
+ not be blank.
+
+ :param title: The new title of the feed.
+ :returns: The feeds title.
+ '''
+ if title is not None:
+ self.__atom_title = title
+ self.__rss_title = title
+ return self.__atom_title
+
+ def id(self, id=None):
+ '''Get or set the feed id which identifies the feed using a universally
+ unique and permanent URI. If you have a long-term, renewable lease on
+ your Internet domain name, then you can feel free to use your website's
+ address. This field is for ATOM only. It is mandatory for ATOM.
+
+ :param id: New Id of the ATOM feed.
+ :returns: Id of the feed.
+ '''
+
+ if id is not None:
+ self.__atom_id = id
+ return self.__atom_id
+
+ def updated(self, updated=None):
+ '''Set or get the updated value which indicates the last time the feed
+ was modified in a significant way.
+
+ The value can either be a string which will automatically be parsed or
+ a datetime.datetime object. In any case it is necessary that the value
+ include timezone information.
+
+ This will set both atom:updated and rss:lastBuildDate.
+
+ Default value
+ If not set, updated has as value the current date and time.
+
+ :param updated: The modification date.
+ :returns: Modification date as datetime.datetime
+ '''
+ if updated is not None:
+ if isinstance(updated, string_types):
+ updated = dateutil.parser.parse(updated)
+ if not isinstance(updated, datetime):
+ raise ValueError('Invalid datetime format')
+ if updated.tzinfo is None:
+ raise ValueError('Datetime object has no timezone info')
+ self.__atom_updated = updated
+ self.__rss_lastBuildDate = updated
+
+ return self.__atom_updated
+
+ def lastBuildDate(self, lastBuildDate=None):
+ '''Set or get the lastBuildDate value which indicates the last time the
+ content of the channel changed.
+
+ The value can either be a string which will automatically be parsed or
+ a datetime.datetime object. In any case it is necessary that the value
+ include timezone information.
+
+ This will set both atom:updated and rss:lastBuildDate.
+
+ Default value
+ If not set, lastBuildDate has as value the current date and time.
+
+ :param lastBuildDate: The modification date.
+ :returns: Modification date as datetime.datetime
+ '''
+ return self.updated(lastBuildDate)
+
+ def author(self, author=None, replace=False, **kwargs):
+ '''Get or set author data. An author element is a dictionary containing
+ a name, an email address and a URI. Name is mandatory for ATOM, email
+ is mandatory for RSS.
+
+ This method can be called with:
+
+ - the fields of an author as keyword arguments
+ - the fields of an author as a dictionary
+ - a list of dictionaries containing the author fields
+
+ An author has the following fields:
+
+ - *name* conveys a human-readable name for the person.
+ - *uri* contains a home page for the person.
+ - *email* contains an email address for the person.
+
+ :param author: Dictionary or list of dictionaries with author data.
+ :param replace: Add or replace old data.
+ :returns: List of authors as dictionaries.
+
+ Example::
+
+ >>> feedgen.author({'name':'John Doe', 'email':'jdoe@example.com'})
+ [{'name':'John Doe','email':'jdoe@example.com'}]
+
+ >>> feedgen.author([{'name':'Mr. X'},{'name':'Max'}])
+ [{'name':'John Doe','email':'jdoe@example.com'},
+ {'name':'John Doe'}, {'name':'Max'}]
+
+ >>> feedgen.author(name='John Doe', email='jdoe@example.com',
+ replace=True)
+ [{'name':'John Doe','email':'jdoe@example.com'}]
+
+ '''
+ if author is None and kwargs:
+ author = kwargs
+ if author is not None:
+ if replace or self.__atom_author is None:
+ self.__atom_author = []
+ self.__atom_author += ensure_format(author,
+ set(['name', 'email', 'uri']),
+ set(['name']))
+ self.__rss_author = []
+ for a in self.__atom_author:
+ if a.get('email'):
+ self.__rss_author.append(a['email'])
+ return self.__atom_author
+
+ def link(self, link=None, replace=False, **kwargs):
+ '''Get or set link data. An link element is a dict with the fields
+ href, rel, type, hreflang, title, and length. Href is mandatory for
+ ATOM.
+
+ This method can be called with:
+
+ - the fields of a link as keyword arguments
+ - the fields of a link as a dictionary
+ - a list of dictionaries containing the link fields
+
+ A link has the following fields:
+
+ - *href* is the URI of the referenced resource (typically a Web page)
+ - *rel* contains a single link relationship type. It can be a full URI,
+ or one of the following predefined values (default=alternate):
+
+ - *alternate* an alternate representation of the entry or feed, for
+ example a permalink to the html version of the entry, or the
+ front page of the weblog.
+ - *enclosure* a related resource which is potentially large in size
+ and might require special handling, for example an audio or video
+ recording.
+ - *related* an document related to the entry or feed.
+ - *self* the feed itself.
+ - *via* the source of the information provided in the entry.
+
+ - *type* indicates the media type of the resource.
+ - *hreflang* indicates the language of the referenced resource.
+ - *title* human readable information about the link, typically for
+ display purposes.
+ - *length* the length of the resource, in bytes.
+
+ RSS only supports one link with URL only.
+
+ :param link: Dict or list of dicts with data.
+ :param replace: If old links are to be replaced (default: False)
+ :returns: Current set of link data
+
+ Example::
+
+ >>> feedgen.link( href='http://example.com/', rel='self')
+ [{'href':'http://example.com/', 'rel':'self'}]
+
+ '''
+ if link is None and kwargs:
+ link = kwargs
+ if link is not None:
+ if replace or self.__atom_link is None:
+ self.__atom_link = []
+ self.__atom_link += ensure_format(
+ link,
+ set(['href', 'rel', 'type', 'hreflang', 'title', 'length']),
+ set(['href']),
+ {'rel': [
+ 'about', 'alternate', 'appendix', 'archives', 'author',
+ 'bookmark', 'canonical', 'chapter', 'collection',
+ 'contents', 'copyright', 'create-form', 'current',
+ 'derivedfrom', 'describedby', 'describes', 'disclosure',
+ 'duplicate', 'edit', 'edit-form', 'edit-media',
+ 'enclosure', 'first', 'glossary', 'help', 'hosts', 'hub',
+ 'icon', 'index', 'item', 'last', 'latest-version',
+ 'license', 'lrdd', 'memento', 'monitor', 'monitor-group',
+ 'next', 'next-archive', 'nofollow', 'noreferrer',
+ 'original', 'payment', 'predecessor-version', 'prefetch',
+ 'prev', 'preview', 'previous', 'prev-archive',
+ 'privacy-policy', 'profile', 'related', 'replies',
+ 'search', 'section', 'self', 'service', 'start',
+ 'stylesheet', 'subsection', 'successor-version', 'tag',
+ 'terms-of-service', 'timegate', 'timemap', 'type', 'up',
+ 'version-history', 'via', 'working-copy', 'working-copy-of'
+ ]})
+ # RSS only needs one URL. We use the first link for RSS:
+ if len(self.__atom_link) > 0:
+ self.__rss_link = self.__atom_link[-1]['href']
+ # return the set with more information (atom)
+ return self.__atom_link
+
+ def category(self, category=None, replace=False, **kwargs):
+ '''Get or set categories that the feed belongs to.
+
+ This method can be called with:
+
+ - the fields of a category as keyword arguments
+ - the fields of a category as a dictionary
+ - a list of dictionaries containing the category fields
+
+ A categories has the following fields:
+
+ - *term* identifies the category
+ - *scheme* identifies the categorization scheme via a URI.
+ - *label* provides a human-readable label for display
+
+ If a label is present it is used for the RSS feeds. Otherwise the term
+ is used. The scheme is used for the domain attribute in RSS.
+
+ :param category: Dict or list of dicts with data.
+ :param replace: Add or replace old data.
+ :returns: List of category data.
+ '''
+ if category is None and kwargs:
+ category = kwargs
+ if category is not None:
+ if replace or self.__atom_category is None:
+ self.__atom_category = []
+ self.__atom_category += ensure_format(
+ category,
+ set(['term', 'scheme', 'label']),
+ set(['term']))
+ # Map the ATOM categories to RSS categories. Use the atom:label as
+ # name or if not present the atom:term. The atom:scheme is the
+ # rss:domain.
+ self.__rss_category = []
+ for cat in self.__atom_category:
+ rss_cat = {}
+ rss_cat['value'] = cat.get('label', cat['term'])
+ if cat.get('scheme'):
+ rss_cat['domain'] = cat['scheme']
+ self.__rss_category.append(rss_cat)
+ return self.__atom_category
+
+ def cloud(self, domain=None, port=None, path=None, registerProcedure=None,
+ protocol=None):
+ '''Set or get the cloud data of the feed. It is an RSS only attribute.
+ It specifies a web service that supports the rssCloud interface which
+ can be implemented in HTTP-POST, XML-RPC or SOAP 1.1.
+
+ :param domain: The domain where the webservice can be found.
+ :param port: The port the webservice listens to.
+ :param path: The path of the webservice.
+ :param registerProcedure: The procedure to call.
+ :param protocol: Can be either HTTP-POST, XML-RPC or SOAP 1.1.
+ :returns: Dictionary containing the cloud data.
+ '''
+ if domain is not None:
+ self.__rss_cloud = {'domain': domain, 'port': port, 'path': path,
+ 'registerProcedure': registerProcedure,
+ 'protocol': protocol}
+ return self.__rss_cloud
+
+ def contributor(self, contributor=None, replace=False, **kwargs):
+ '''Get or set the contributor data of the feed. This is an ATOM only
+ value.
+
+ This method can be called with:
+ - the fields of an contributor as keyword arguments
+ - the fields of an contributor as a dictionary
+ - a list of dictionaries containing the contributor fields
+
+ An contributor has the following fields:
+ - *name* conveys a human-readable name for the person.
+ - *uri* contains a home page for the person.
+ - *email* contains an email address for the person.
+
+ :param contributor: Dictionary or list of dictionaries with contributor
+ data.
+ :param replace: Add or replace old data.
+ :returns: List of contributors as dictionaries.
+ '''
+ if contributor is None and kwargs:
+ contributor = kwargs
+ if contributor is not None:
+ if replace or self.__atom_contributor is None:
+ self.__atom_contributor = []
+ self.__atom_contributor += ensure_format(
+ contributor, set(['name', 'email', 'uri']), set(['name']))
+ return self.__atom_contributor
+
+ def generator(self, generator=None, version=None, uri=None):
+ '''Get or set the generator of the feed which identifies the software
+ used to generate the feed, for debugging and other purposes. Both the
+ uri and version attributes are optional and only available in the ATOM
+ feed.
+
+ :param generator: Software used to create the feed.
+ :param version: Version of the software.
+ :param uri: URI the software can be found.
+ '''
+ if generator is not None:
+ self.__atom_generator = {'value': generator}
+ if version is not None:
+ self.__atom_generator['version'] = version
+ if uri is not None:
+ self.__atom_generator['uri'] = uri
+ self.__rss_generator = generator
+ return self.__atom_generator
+
+ def icon(self, icon=None):
+ '''Get or set the icon of the feed which is a small image which
+ provides iconic visual identification for the feed. Icons should be
+ square. This is an ATOM only value.
+
+ :param icon: URI of the feeds icon.
+ :returns: URI of the feeds icon.
+ '''
+ if icon is not None:
+ self.__atom_icon = icon
+ return self.__atom_icon
+
+ def logo(self, logo=None):
+ '''Get or set the logo of the feed which is a larger image which
+ provides visual identification for the feed. Images should be twice as
+ wide as they are tall. This is an ATOM value but will also set the
+ rss:image value.
+
+ :param logo: Logo of the feed.
+ :returns: Logo of the feed.
+ '''
+ if logo is not None:
+ self.__atom_logo = logo
+ self.__rss_image = {'url': logo}
+ return self.__atom_logo
+
+ def image(self, url=None, title=None, link=None, width=None, height=None,
+ description=None):
+ '''Set the image of the feed. This element is roughly equivalent to
+ atom:logo.
+
+ :param url: The URL of a GIF, JPEG or PNG image.
+ :param title: Describes the image. The default value is the feeds
+ title.
+ :param link: URL of the site the image will link to. The default is to
+ use the feeds first alternate link.
+ :param width: Width of the image in pixel. The maximum is 144.
+ :param height: The height of the image. The maximum is 400.
+ :param description: Title of the link.
+ :returns: Data of the image as dictionary.
+ '''
+ if url is not None:
+ self.__rss_image = {'url': url}
+ if title is not None:
+ self.__rss_image['title'] = title
+ if link is not None:
+ self.__rss_image['link'] = link
+ if width:
+ self.__rss_image['width'] = width
+ if height:
+ self.__rss_image['height'] = height
+ self.__atom_logo = url
+ return self.__rss_image
+
+ def rights(self, rights=None):
+ '''Get or set the rights value of the feed which conveys information
+ about rights, e.g. copyrights, held in and over the feed. This ATOM
+ value will also set rss:copyright.
+
+ :param rights: Rights information of the feed.
+ '''
+ if rights is not None:
+ self.__atom_rights = rights
+ self.__rss_copyright = rights
+ return self.__atom_rights
+
+ def copyright(self, copyright=None):
+ '''Get or set the copyright notice for content in the channel. This RSS
+ value will also set the atom:rights value.
+
+ :param copyright: The copyright notice.
+ :returns: The copyright notice.
+ '''
+ return self.rights(copyright)
+
+ def subtitle(self, subtitle=None):
+ '''Get or set the subtitle value of the channel which contains a
+ human-readable description or subtitle for the feed. This ATOM property
+ will also set the value for rss:description.
+
+ :param subtitle: The subtitle of the feed.
+ :returns: The subtitle of the feed.
+ '''
+ if subtitle is not None:
+ self.__atom_subtitle = subtitle
+ self.__rss_description = subtitle
+ return self.__atom_subtitle
+
+ def description(self, description=None):
+ '''Set and get the description of the feed. This is an RSS only element
+ which is a phrase or sentence describing the channel. It is mandatory
+ for RSS feeds. It is roughly the same as atom:subtitle. Thus setting
+ this will also set atom:subtitle.
+
+ :param description: Description of the channel.
+ :returns: Description of the channel.
+
+ '''
+ return self.subtitle(description)
+
+ def docs(self, docs=None):
+ '''Get or set the docs value of the feed. This is an RSS only value. It
+ is a URL that points to the documentation for the format used in the
+ RSS file. It is probably a pointer to [1]. It is for people who might
+ stumble across an RSS file on a Web server 25 years from now and wonder
+ what it is.
+
+ [1]: http://www.rssboard.org/rss-specification
+
+ :param docs: URL of the format documentation.
+ :returns: URL of the format documentation.
+ '''
+ if docs is not None:
+ self.__rss_docs = docs
+ return self.__rss_docs
+
+ def language(self, language=None):
+ '''Get or set the language of the feed. It indicates the language the
+ channel is written in. This allows aggregators to group all Italian
+ language sites, for example, on a single page. This is an RSS only
+ field. However, this value will also be used to set the xml:lang
+ property of the ATOM feed node.
+ The value should be an IETF language tag.
+
+ :param language: Language of the feed.
+ :returns: Language of the feed.
+ '''
+ if language is not None:
+ self.__rss_language = language
+ self.__atom_feed_xml_lang = language
+ return self.__rss_language
+
+ def managingEditor(self, managingEditor=None):
+ '''Set or get the value for managingEditor which is the email address
+ for person responsible for editorial content. This is a RSS only
+ value.
+
+ :param managingEditor: Email address of the managing editor.
+ :returns: Email address of the managing editor.
+ '''
+ if managingEditor is not None:
+ self.__rss_managingEditor = managingEditor
+ return self.__rss_managingEditor
+
+ def pubDate(self, pubDate=None):
+ '''Set or get the publication date for the content in the channel. For
+ example, the New York Times publishes on a daily basis, the publication
+ date flips once every 24 hours. That's when the pubDate of the channel
+ changes.
+
+ The value can either be a string which will automatically be parsed or
+ a datetime.datetime object. In any case it is necessary that the value
+ include timezone information.
+
+ This will set both atom:updated and rss:lastBuildDate.
+
+ :param pubDate: The publication date.
+ :returns: Publication date as datetime.datetime
+ '''
+ if pubDate is not None:
+ if isinstance(pubDate, string_types):
+ pubDate = dateutil.parser.parse(pubDate)
+ if not isinstance(pubDate, datetime):
+ raise ValueError('Invalid datetime format')
+ if pubDate.tzinfo is None:
+ raise ValueError('Datetime object has no timezone info')
+ self.__rss_pubDate = pubDate
+
+ return self.__rss_pubDate
+
+ def rating(self, rating=None):
+ '''Set and get the PICS rating for the channel. It is an RSS only
+ value.
+ '''
+ if rating is not None:
+ self.__rss_rating = rating
+ return self.__rss_rating
+
+ def skipHours(self, hours=None, replace=False):
+ '''Set or get the value of skipHours, a hint for aggregators telling
+ them which hours they can skip. This is an RSS only value.
+
+ This method can be called with an hour or a list of hours. The hours
+ are represented as integer values from 0 to 23.
+
+ :param hours: List of hours the feedreaders should not check the feed.
+ :param replace: Add or replace old data.
+ :returns: List of hours the feedreaders should not check the feed.
+ '''
+ if hours is not None:
+ if not (isinstance(hours, list) or isinstance(hours, set)):
+ hours = [hours]
+ for h in hours:
+ if h not in range(24):
+ raise ValueError('Invalid hour %s' % h)
+ if replace or not self.__rss_skipHours:
+ self.__rss_skipHours = set()
+ self.__rss_skipHours |= set(hours)
+ return self.__rss_skipHours
+
+ def skipDays(self, days=None, replace=False):
+ '''Set or get the value of skipDays, a hint for aggregators telling
+ them which days they can skip This is an RSS only value.
+
+ This method can be called with a day name or a list of day names. The
+ days are represented as strings from 'Monday' to 'Sunday'.
+
+ :param hours: List of days the feedreaders should not check the feed.
+ :param replace: Add or replace old data.
+ :returns: List of days the feedreaders should not check the feed.
+ '''
+ if days is not None:
+ if not (isinstance(days, list) or isinstance(days, set)):
+ days = [days]
+ for d in days:
+ if d not in ['Monday', 'Tuesday', 'Wednesday', 'Thursday',
+ 'Friday', 'Saturday', 'Sunday']:
+ raise ValueError('Invalid day %s' % d)
+ if replace or not self.__rss_skipDays:
+ self.__rss_skipDays = set()
+ self.__rss_skipDays |= set(days)
+ return self.__rss_skipDays
+
+ def textInput(self, title=None, description=None, name=None, link=None):
+ '''Get or set the value of textInput. This is an RSS only field. The
+ purpose of the element is something of a mystery. You can
+ use it to specify a search engine box. Or to allow a reader to provide
+ feedback. Most aggregators ignore it.
+
+ :param title: The label of the Submit button in the text input area.
+ :param description: Explains the text input area.
+ :param name: The name of the text object in the text input area.
+ :param link: The URL of the CGI script that processes text input
+ requests.
+ :returns: Dictionary containing textInput values.
+ '''
+ if title is not None:
+ self.__rss_textInput = {}
+ self.__rss_textInput['title'] = title
+ self.__rss_textInput['description'] = description
+ self.__rss_textInput['name'] = name
+ self.__rss_textInput['link'] = link
+ return self.__rss_textInput
+
+ def ttl(self, ttl=None):
+ '''Get or set the ttl value. It is an RSS only element. ttl stands for
+ time to live. It's a number of minutes that indicates how long a
+ channel can be cached before refreshing from the source.
+
+ :param ttl: Integer value indicating how long the channel may be
+ cached.
+ :returns: Time to live.
+ '''
+ if ttl is not None:
+ self.__rss_ttl = int(ttl)
+ return self.__rss_ttl
+
+ def webMaster(self, webMaster=None):
+ '''Get and set the value of webMaster, which represents the email
+ address for the person responsible for technical issues relating to the
+ feed. This is an RSS only value.
+
+ :param webMaster: Email address of the webmaster.
+ :returns: Email address of the webmaster.
+ '''
+ if webMaster is not None:
+ self.__rss_webMaster = webMaster
+ return self.__rss_webMaster
+
+ def add_entry(self, feedEntry=None, order='prepend'):
+ '''This method will add a new entry to the feed. If the feedEntry
+ argument is omitted a new Entry object is created automatically. This
+ is the preferred way to add new entries to a feed.
+
+ :param feedEntry: FeedEntry object to add.
+ :param order: If `prepend` is chosen, the entry will be inserted
+ at the beginning of the feed. If `append` is chosen,
+ the entry will be appended to the feed.
+ (default: `prepend`).
+ :returns: FeedEntry object created or passed to this function.
+
+ Example::
+
+ ...
+ >>> entry = feedgen.add_entry()
+ >>> entry.title('First feed entry')
+
+ '''
+ if feedEntry is None:
+ feedEntry = FeedEntry()
+
+ version = sys.version_info[0]
+
+ if version == 2:
+ items = self.__extensions.iteritems()
+ else:
+ items = self.__extensions.items()
+
+ # Try to load extensions:
+ for extname, ext in items:
+ try:
+ feedEntry.register_extension(extname,
+ ext['extension_class_entry'],
+ ext['atom'],
+ ext['rss'])
+ except ImportError:
+ pass
+
+ if order == 'prepend':
+ self.__feed_entries.insert(0, feedEntry)
+ else:
+ 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
+ omitted a new FeedEntry object is created automatically. This is just
+ another name for add_entry(...)
+ '''
+ return self.add_entry(item)
+
+ def entry(self, entry=None, replace=False):
+ '''Get or set feed entries. Use the add_entry() method instead to
+ automatically create the FeedEntry objects.
+
+ This method takes both a single FeedEntry object or a list of objects.
+
+ :param entry: FeedEntry object or list of FeedEntry objects.
+ :returns: List ob all feed entries.
+ '''
+ if entry is not None:
+ if not isinstance(entry, list):
+ entry = [entry]
+ if replace:
+ self.__feed_entries = []
+
+ version = sys.version_info[0]
+
+ if version == 2:
+ items = self.__extensions.iteritems()
+ else:
+ items = self.__extensions.items()
+
+ # Try to load extensions:
+ for e in entry:
+ for extname, ext in items:
+ try:
+ e.register_extension(extname,
+ ext['extension_class_entry'],
+ ext['atom'], ext['rss'])
+ except ImportError:
+ pass
+
+ self.__feed_entries += entry
+ return self.__feed_entries
+
+ def item(self, item=None, replace=False):
+ '''Get or set feed items. This is just another name for entry(...)
+ '''
+ return self.entry(item, replace)
+
+ def remove_entry(self, entry):
+ '''Remove a single entry from the feed. This method accepts both the
+ FeedEntry object to remove or the index of the entry as argument.
+
+ :param entry: Entry or index of entry to remove.
+ '''
+ if isinstance(entry, FeedEntry):
+ self.__feed_entries.remove(entry)
+ else:
+ self.__feed_entries.pop(entry)
+
+ def remove_item(self, item):
+ '''Remove a single item from the feed. This is another name for
+ remove_entry.
+ '''
+ self.remove_entry(item)
+
+ def load_extension(self, name, atom=True, rss=True):
+ '''Load a specific extension by name.
+
+ :param name: Name of the extension to load.
+ :param atom: If the extension should be used for ATOM feeds.
+ :param rss: If the extension should be used for RSS feeds.
+ '''
+ # Check loaded extensions
+ if not isinstance(self.__extensions, dict):
+ self.__extensions = {}
+ if name in self.__extensions.keys():
+ raise ImportError('Extension already loaded')
+
+ # Load extension
+ extname = name[0].upper() + name[1:]
+ feedsupmod = __import__('feedgen.ext.%s' % name)
+ feedextmod = getattr(feedsupmod.ext, name)
+ try:
+ entrysupmod = __import__('feedgen.ext.%s_entry' % name)
+ entryextmod = getattr(entrysupmod.ext, name + '_entry')
+ except ImportError:
+ # Use FeedExtension module instead
+ entrysupmod = feedsupmod
+ entryextmod = feedextmod
+ feedext = getattr(feedextmod, extname + 'Extension')
+ try:
+ entryext = getattr(entryextmod, extname + 'EntryExtension')
+ except AttributeError:
+ entryext = None
+ self.register_extension(name, feedext, entryext, atom, rss)
+
+ def register_extension(self, namespace, extension_class_feed=None,
+ extension_class_entry=None, atom=True, rss=True):
+ '''Registers an extension by class.
+
+ :param namespace: namespace for the extension
+ :param extension_class_feed: Class of the feed extension to load.
+ :param extension_class_entry: Class of the entry extension to load
+ :param atom: If the extension should be used for ATOM feeds.
+ :param rss: If the extension should be used for RSS feeds.
+ '''
+ # Check loaded extensions
+ # `load_extension` ignores the "Extension" suffix.
+ if not isinstance(self.__extensions, dict):
+ self.__extensions = {}
+ if namespace in self.__extensions.keys():
+ raise ImportError('Extension already loaded')
+
+ # Load extension
+ extinst = extension_class_feed()
+ setattr(self, namespace, extinst)
+
+ # `load_extension` registry
+ self.__extensions[namespace] = {
+ 'inst': extinst,
+ 'extension_class_feed': extension_class_feed,
+ 'extension_class_entry': extension_class_entry,
+ 'atom': atom,
+ 'rss': rss
+ }
+
+ # Try to load the extension for already existing entries:
+ for entry in self.__feed_entries:
+ try:
+ entry.register_extension(namespace,
+ extension_class_entry,
+ atom,
+ rss)
+ except ImportError:
+ pass
diff --git a/feedgen/util.py b/feedgen/util.py
index 3f3d97e..18c1c0e 100644
--- a/feedgen/util.py
+++ b/feedgen/util.py
@@ -1,62 +1,96 @@
# -*- coding: utf-8 -*-
'''
- feedgen.util
- ~~~~~~~~~~~~
+ feedgen.util
+ ~~~~~~~~~~~~
- This file contains helper functions for the feed generator module.
+ This file contains helper functions for the feed generator module.
- :copyright: 2013, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :copyright: 2013, Lars Kiesow
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
+import locale
import sys
+import lxml # nosec - we configure a safe parser below
+
+# Configure a safe parser which does not allow XML entity expansion
+parser = lxml.etree.XMLParser(
+ attribute_defaults=False,
+ dtd_validation=False,
+ load_dtd=False,
+ no_network=True,
+ recover=False,
+ remove_pis=True,
+ resolve_entities=False,
+ huge_tree=False)
+
+
+def xml_fromstring(xmlstring):
+ return lxml.etree.fromstring(xmlstring, parser) # nosec - safe parser
+
+
+def xml_elem(name, parent=None, **kwargs):
+ if parent is not None:
+ return lxml.etree.SubElement(parent, name, **kwargs)
+ return lxml.etree.Element(name, **kwargs)
def ensure_format(val, allowed, required, allowed_values=None, defaults=None):
- '''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
+ '''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 []
+ if allowed_values is None:
+ allowed_values = {}
+ if defaults is None:
+ defaults = {}
+ # Make sure that we have a list of dicts. Even if there is only one.
+ if not isinstance(val, list):
+ val = [val]
+ for elem in val:
+ 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(date):
+ '''Make sure the locale setting do not interfere with the time format.
+ '''
+ old = locale.setlocale(locale.LC_ALL)
+ locale.setlocale(locale.LC_ALL, 'C')
+ date = date.strftime('%a, %d %b %Y %H:%M:%S %z')
+ locale.setlocale(locale.LC_ALL, old)
+ return date
diff --git a/feedgen/version.py b/feedgen/version.py
index f6afdaa..c8f76bf 100644
--- a/feedgen/version.py
+++ b/feedgen/version.py
@@ -1,16 +1,16 @@
# -*- coding: utf-8 -*-
'''
- feedgen.version
- ~~~~~~~~~~~~~~~
+ feedgen.version
+ ~~~~~~~~~~~~~~~
- :copyright: 2013, Lars Kiesow
+ :copyright: 2013-2018, Lars Kiesow
- :license: FreeBSD and LGPL, see license.* for more details.
+ :license: FreeBSD and LGPL, see license.* for more details.
'''
'Version of python-feedgen represented as tuple'
-version = (0, 2, 8)
+version = (1, 0, 0)
'Version of python-feedgen represented as string'
@@ -18,8 +18,8 @@
version_major = version[:1]
version_minor = version[:2]
-version_full = version
+version_full = version
version_major_str = '.'.join([str(x) for x in version_major])
version_minor_str = '.'.join([str(x) for x in version_minor])
-version_full_str = '.'.join([str(x) for x in version_full])
+version_full_str = '.'.join([str(x) for x in version_full])
diff --git a/license.bsd b/license.bsd
index a8e1879..beac4ea 100644
--- a/license.bsd
+++ b/license.bsd
@@ -1,31 +1,25 @@
+BSD 2-Clause License
-Copyright 2011 Lars Kiesow. All rights reserved.
-http://www.larskiesow.de
+Copyright 2011, Lars Kiesow
+All rights reserved.
Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions
-are met:
+modification, are permitted provided that the following conditions are met:
-1. Redistributions of source code must retain the above copyright
- notice, this list of conditions and the following disclaimer.
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
-2. Redistributions in binary form must reproduce the above copyright
- notice, this list of conditions and the following disclaimer in the
- documentation and/or other materials provided with the distribution.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY
-EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS
-BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
-THE POSSIBILITY OF SUCH DAMAGE.
-
-The views and conclusions contained in the software and documentation
-are those of the authors and should not be interpreted as representing
-official policies, either expressed or implied, of everyone working on
-this project.
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/python-feedgen.spec b/python-feedgen.spec
index 6930c1a..a137396 100644
--- a/python-feedgen.spec
+++ b/python-feedgen.spec
@@ -1,77 +1,67 @@
-%define srcname feedgen
+%global pypi_name feedgen
+%global pypi_version 1.0.0
-Name: python-%{srcname}
-Version: 0.2.8
+Name: python-%{pypi_name}
+Version: %{pypi_version}
Release: 1%{?dist}
Summary: Feed Generator (ATOM, RSS, Podcasts)
-Group: Development/Libraries
-License: LGPLv3+ or BSD
-URL: http://lkiesow.github.io/%{name}/
+License: BSD or LGPLv3
+URL: http://lkiesow.github.io/python-feedgen
+#Source0: https://github.com/lkiesow/%{name}/archive/v%{version}.tar.gz
+Source0: %{pypi_source}
+BuildArch: noarch
-Source0: https://pypi.python.org/packages/source/f/%{srcname}/%{srcname}-%{version}.tar.gz
+BuildRequires: python3-devel
+BuildRequires: python3dist(setuptools)
+BuildRequires: python3dist(lxml)
+BuildRequires: python3dist(python-dateutil)
-BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)
+%description
+Feedgenerator: This module can be used to generate web feeds in both ATOM and
+RSS format. It has support for extensions. Included is for example an extension
+to produce Podcasts.
+%package -n python3-%{pypi_name}
+Summary: %{summary}
+%{?python_provide:%python_provide python3-%{pypi_name}}
-BuildArch: noarch
-BuildRequires: python2-devel
-BuildRequires: python-setuptools
-Requires: python-lxml
-Requires: python-dateutil
+Requires: python3dist(python-dateutil)
+Requires: python3dist(lxml)
-%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.
+%description -n python3-%{pypi_name}
+Feedgenerator: This module can be used to generate web feeds in both ATOM and
+RSS format. It has support for extensions. Included is for example an extension
+to produce Podcasts.
%prep
-%setup -q -n %{srcname}-%{version}
-
+%autosetup -n %{pypi_name}-%{pypi_version}
+# Remove bundled egg-info
+rm -rf %{pypi_name}.egg-info
%build
-%{__python} setup.py build
-
+%py3_build
%install
-rm -rf $RPM_BUILD_ROOT
-%{__python} setup.py install -O1 --skip-build --root $RPM_BUILD_ROOT
-chmod 644 $RPM_BUILD_ROOT%{python_sitelib}/%{srcname}/*.py
-
-
-%clean
-rm -rf $RPM_BUILD_ROOT
+%py3_install
+%check
+%{__python3} setup.py test
-%files
-%defattr(-,root,root,-)
-%doc docs/*
-%{python_sitelib}/*
-
+%files -n python3-%{pypi_name}
+%license license.lgpl license.bsd
+%doc readme.rst
+%{python3_sitelib}/%{pypi_name}
+%{python3_sitelib}/%{pypi_name}-%{version}-py?.?.egg-info
%changelog
-* 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
+* Mon Dec 25 2023 Lars Kiesow - 1.0.0-1
+- Update to 1.0.0
+- Removing support for Python 2
-* Sun May 5 2013 Lars Kiesow - 0.2.2-1
-- Update to version 0.2.2
+* Sat May 19 2018 Lars Kiesow - 0.7.0-1
+- Update to 0.7.0
-* Sat May 4 2013 Lars Kiesow - 0.1-1
-- Initial build
+* Tue Oct 24 2017 Lumir Balhar - 0.6.1-1
+- Initial package.
diff --git a/readme.md b/readme.md
deleted file mode 100644
index 6c751ef..0000000
--- a/readme.md
+++ /dev/null
@@ -1,172 +0,0 @@
-=============
-Feedgenerator
-=============
-
-This module can be used to generate web feeds in both ATOM and RSS format. It
-has support for extensions. Included is for example an extension to produce
-Podcasts.
-
-It is licensed under the terms of both, the FreeBSD license and the LGPLv3+.
-Choose the one which is more convenient for you. For more details have a look
-at license.bsd and license.lgpl.
-
-More details about the project:
-
-- Repository: https://github.com/lkiesow/python-feedgen
-- Documentation: http://lkiesow.github.io/python-feedgen/
-- Python Package Index: https://pypi.python.org/pypi/feedgen/
-
-
-------------
-Installation
-------------
-
-**Prebuild packages**
-
-If you are running Fedora Linux, Redhat Enterprise Linux, CentOS or Scientific
-Linux you can use one of the following packages:
-
-- [python-feedgen-0.2.8-1.fc20.noarch.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.fc20.noarch.rpm)
-- [python-feedgen-0.2.8-1.fc19.noarch.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.fc19.noarch.rpm)
-- [python-feedgen-0.2.8-1.fc18.noarch.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.fc18.noarch.rpm)
-- [python-feedgen-0.2.8-1.el6.noarch.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.el6.noarch.rpm)
-
-Simply download the file and run::
-
- $ yum localinstall python-feedgen-...noarch.rpm
-
-If you want to build RPMs for other distributions you can use the following Source RPM:
-
-- [python-feedgen-0.2.8-1.fc20.src.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.fc20.src.rpm)
-
-**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: 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
-
-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).
diff --git a/readme.rst b/readme.rst
new file mode 100644
index 0000000..3edeaff
--- /dev/null
+++ b/readme.rst
@@ -0,0 +1,184 @@
+=============
+Feedgenerator
+=============
+
+This module can be used to generate web feeds in both ATOM and RSS format. It
+has support for extensions. Included is for example an extension to produce
+Podcasts.
+
+It is licensed under the terms of both, the FreeBSD license and the LGPLv3+.
+Choose the one which is more convenient for you. For more details have a look
+at license.bsd and license.lgpl.
+
+More details about the project:
+
+- `Repository `_
+- `Documentation `_
+- `Python Package Index `_
+
+
+------------
+Installation
+------------
+
+**Prebuild packages**
+
+If your distribution includes this project as package, like Fedora Linux does,
+you can simply use your package manager to install the package. For example::
+
+ $ dnf 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 instantiate the FeedGenerator class and insert some
+data:
+
+.. code-block:: python
+
+ from feedgen.feed import FeedGenerator
+ fg = FeedGenerator()
+ fg.id('http://lernfunk.de/media/654321')
+ fg.title('Some Testfeed')
+ fg.author( {'name':'John Doe','email':'john@example.de'} )
+ fg.link( href='http://example.com', rel='alternate' )
+ fg.logo('http://ex.com/logo.jpg')
+ fg.subtitle('This is a cool feed!')
+ fg.link( href='http://larskiesow.de/test.atom', rel='self' )
+ fg.language('en')
+
+Note that for the methods which set fields that can occur more than once in a
+feed you can use all of the following ways to provide data:
+
+- 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:
+
+.. code-block:: python
+
+ fg.contributor( name='John Doe', email='jdoe@example.com' )
+ fg.contributor({'name':'John Doe', 'email':'jdoe@example.com'})
+ fg.contributor([{'name':'John Doe', 'email':'jdoe@example.com'}, ...])
+
+-----------------
+Generate the Feed
+-----------------
+
+After that you can generate both RSS or ATOM by calling the respective method:
+
+.. code-block:: python
+
+ atomfeed = fg.atom_str(pretty=True) # Get the ATOM feed as string
+ rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string
+ fg.atom_file('atom.xml') # Write the ATOM feed to a file
+ fg.rss_file('rss.xml') # Write the RSS feed to a file
+
+
+----------------
+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:
+
+.. code-block:: python
+
+ fe = fg.add_entry()
+ fe.id('http://lernfunk.de/media/654321/1')
+ fe.title('The First Episode')
+ fe.link(href="http://lernfunk.de/feed")
+
+The FeedGenerator's method `add_entry(...)` will generate a new FeedEntry
+object, automatically append it to the feeds internal list of entries and
+return it, so that additional data can be added.
+
+----------
+Extensions
+----------
+
+The FeedGenerator supports extensions to include additional data into the XML
+structure of the feeds. Extensions can be loaded like this:
+
+.. code-block:: python
+
+ fg.load_extension('someext', atom=True, rss=True)
+
+This example would try to load the extension “someext” from the file
+`ext/someext.py`. It is required that `someext.py` contains a class named
+“SomextExtension” which is required to have at least the two methods
+`extend_rss(...)` and `extend_atom(...)`. Although not required, it is strongly
+suggested to use `BaseExtension` from `ext/base.py` as superclass.
+
+`load_extension('someext', ...)` will also try to load a class named
+“SomextEntryExtension” for every entry of the feed. This class can be located
+either in the same file as SomextExtension or in `ext/someext_entry.py` which
+is suggested especially for large extensions.
+
+The parameters `atom` and `rss` control if the extension is used for ATOM and
+RSS feeds respectively. The default value for both parameters is `True`,
+meaning the extension is 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:
+
+.. code-block:: python
+
+ 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')
+
+If the FeedGenerator class is used to load an extension, it is automatically
+loaded for every feed entry as well. You can, however, load an extension for a
+specific FeedEntry only by calling `load_extension(...)` on that entry.
+
+Even if extensions are loaded, they can be temporarily disabled during the feed
+generation by calling the generating method with the keyword argument
+`extensions` set to `False`.
+
+**Custom Extensions**
+
+If you want to load custom extensions which are not part of the feedgen
+package, you can use the method `register_extension` instead. You can directly
+pass the classes for the feed and the entry extension to this method meaning
+that you can define them everywhere.
+
+
+---------------------
+Testing the Generator
+---------------------
+
+You can test the module by simply executing::
+
+ $ python -m feedgen
+
+If you want to have a look at the code for this test to have a working code
+example for a whole feed generation process, you can find it in the
+`__main__.py `_.
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..631c4c2
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+lxml>=4.2.5
+python_dateutil>=2.8.0
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..3c6e79c
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal=1
diff --git a/setup.py b/setup.py
index 059dcb7..a5e2021 100755
--- a/setup.py
+++ b/setup.py
@@ -1,40 +1,43 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
-from distutils.core import setup
+from setuptools import setup
+
import feedgen.version
-setup(
- name = 'feedgen',
- packages = ['feedgen', 'feedgen/ext'],
- version = feedgen.version.version_full_str,
- description = 'Feed Generator (ATOM, RSS, Podcasts)',
- author = 'Lars Kiesow',
- author_email = 'lkiesow@uos.de',
- url = 'http://lkiesow.github.io/python-feedgen',
- keywords = ['feed','ATOM','RSS','podcast'],
- license = 'FreeBSD and LGPLv3+',
- install_requires = ['lxml', 'dateutils'],
- classifiers = [
- 'Development Status :: 4 - Beta',
- 'Development Status :: 5 - Production/Stable',
- 'Intended Audience :: Developers',
- 'Intended Audience :: Information Technology',
- 'Intended Audience :: Science/Research',
- 'License :: OSI Approved :: BSD License',
- 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)',
- 'Natural Language :: English',
- 'Operating System :: OS Independent',
- 'Programming Language :: Python',
- 'Programming Language :: Python :: 2',
- 'Programming Language :: Python :: 3',
- 'Topic :: Communications',
- 'Topic :: Internet',
- 'Topic :: Text Processing',
- 'Topic :: Text Processing :: Markup',
- 'Topic :: Text Processing :: Markup :: XML'
- ],
- long_description = '''\
+packages = ['feedgen', 'feedgen/ext']
+
+setup(name='feedgen',
+ packages=packages,
+ version=feedgen.version.version_full_str,
+ description='Feed Generator (ATOM, RSS, Podcasts)',
+ author='Lars Kiesow',
+ author_email='lkiesow@uos.de',
+ url='https://lkiesow.github.io/python-feedgen',
+ keywords=['feed', 'ATOM', 'RSS', 'podcast'],
+ license='FreeBSD and LGPLv3+',
+ install_requires=['lxml', 'python-dateutil'],
+ classifiers=[
+ 'Development Status :: 5 - Production/Stable',
+ 'Intended Audience :: Developers',
+ 'Intended Audience :: Information Technology',
+ 'Intended Audience :: Science/Research',
+ 'License :: OSI Approved :: BSD License',
+ 'License :: OSI Approved :: GNU Lesser General Public License v3 ' +
+ 'or later (LGPLv3+)',
+ 'Natural Language :: English',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 3',
+ 'Topic :: Communications',
+ 'Topic :: Internet',
+ 'Topic :: Text Processing',
+ 'Topic :: Text Processing :: Markup',
+ 'Topic :: Text Processing :: Markup :: XML'
+ ],
+ test_suite="tests",
+ long_description='''\
Feedgenerator
=============
@@ -45,5 +48,4 @@
It is licensed under the terms of both, the FreeBSD license and the LGPLv3+.
Choose the one which is more convenient for you. For more details have a look
at license.bsd and license.lgpl.
-'''
-)
+''')
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_entry.py b/tests/test_entry.py
new file mode 100644
index 0000000..adfd8b5
--- /dev/null
+++ b/tests/test_entry.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+
+"""
+Tests for a basic entry
+
+These are test cases for a basic entry.
+"""
+
+import unittest
+
+from feedgen.feed import FeedGenerator
+
+
+class TestSequenceFunctions(unittest.TestCase):
+
+ def setUp(self):
+ fg = FeedGenerator()
+ self.feedId = 'http://example.com'
+ self.title = 'Some Testfeed'
+
+ fg.id(self.feedId)
+ fg.title(self.title)
+ fg.link(href='http://lkiesow.de', rel='alternate')[0]
+ fg.description('...')
+
+ fe = fg.add_entry()
+ fe.id('http://lernfunk.de/media/654321/1')
+ fe.title('The First Episode')
+ fe.content(u'…')
+
+ # Use also the different name add_item
+ fe = fg.add_item()
+ fe.id('http://lernfunk.de/media/654321/1')
+ fe.title('The Second Episode')
+ fe.content(u'…')
+
+ fe = fg.add_entry()
+ fe.id('http://lernfunk.de/media/654321/1')
+ fe.title('The Third Episode')
+ fe.content(u'…')
+
+ self.fg = fg
+
+ def test_setEntries(self):
+ fg2 = FeedGenerator()
+ fg2.entry(self.fg.entry())
+ self.assertEqual(len(fg2.entry()), 3)
+ self.assertEqual(self.fg.entry(), fg2.entry())
+
+ def test_loadExtension(self):
+ fe = self.fg.add_item()
+ fe.id('1')
+ fe.title(u'…')
+ fe.content(u'…')
+ fe.load_extension('base')
+ self.assertTrue(fe.base)
+ self.assertTrue(self.fg.atom_str())
+
+ def test_checkEntryNumbers(self):
+ fg = self.fg
+ self.assertEqual(len(fg.entry()), 3)
+
+ def test_TestEntryItems(self):
+ fe = self.fg.add_item()
+ fe.title('qwe')
+ self.assertEqual(fe.title(), 'qwe')
+ author = fe.author(email='ldoe@example.com')[0]
+ self.assertFalse(author.get('name'))
+ self.assertEqual(author.get('email'), 'ldoe@example.com')
+ author = fe.author(name='John Doe', email='jdoe@example.com',
+ replace=True)[0]
+ self.assertEqual(author.get('name'), 'John Doe')
+ self.assertEqual(author.get('email'), 'jdoe@example.com')
+ contributor = fe.contributor(name='John Doe', email='jdoe@ex.com')[0]
+ self.assertEqual(contributor, fe.contributor()[0])
+ self.assertEqual(contributor.get('name'), 'John Doe')
+ self.assertEqual(contributor.get('email'), 'jdoe@ex.com')
+ link = fe.link(href='http://lkiesow.de', rel='alternate')[0]
+ self.assertEqual(link, fe.link()[0])
+ self.assertEqual(link.get('href'), 'http://lkiesow.de')
+ self.assertEqual(link.get('rel'), 'alternate')
+ fe.guid('123')
+ self.assertEqual(fe.guid().get('guid'), '123')
+ fe.updated('2017-02-05 13:26:58+01:00')
+ self.assertEqual(fe.updated().year, 2017)
+ fe.summary('asdf')
+ self.assertEqual(fe.summary(), {'summary': 'asdf'})
+ fe.description('asdfx')
+ self.assertEqual(fe.description(), 'asdfx')
+ fe.pubDate('2017-02-05 13:26:58+01:00')
+ self.assertEqual(fe.pubDate().year, 2017)
+ fe.rights('asdfx')
+ self.assertEqual(fe.rights(), 'asdfx')
+ source = fe.source(url='https://example.com', title='Test')
+ self.assertEqual(source.get('title'), 'Test')
+ self.assertEqual(source.get('url'), 'https://example.com')
+ fe.comments('asdfx')
+ self.assertEqual(fe.comments(), 'asdfx')
+ fe.enclosure(url='http://lkiesow.de', type='text/plain', length='1')
+ self.assertEqual(fe.enclosure().get('url'), 'http://lkiesow.de')
+ fe.ttl(8)
+ self.assertEqual(fe.ttl(), 8)
+
+ self.fg.rss_str()
+ self.fg.atom_str()
+
+ def test_checkItemNumbers(self):
+ fg = self.fg
+ self.assertEqual(len(fg.item()), 3)
+
+ def test_checkEntryContent(self):
+ fg = self.fg
+ self.assertTrue(fg.entry())
+
+ def test_removeEntryByIndex(self):
+ fg = FeedGenerator()
+ self.feedId = 'http://example.com'
+ self.title = 'Some Testfeed'
+
+ fe = fg.add_entry()
+ fe.id('http://lernfunk.de/media/654321/1')
+ fe.title('The Third Episode')
+ self.assertEqual(len(fg.entry()), 1)
+ fg.remove_entry(0)
+ self.assertEqual(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')
+
+ self.assertEqual(len(fg.entry()), 1)
+ fg.remove_entry(fe)
+ self.assertEqual(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://somedomain.com/category',
+ 'label': 'Category',
+ }])
+
+ result = fg.rss_str()
+ self.assertIn(b'domain="http://somedomain.com/category"', result)
+
+ def test_content_cdata_type(self):
+ fg = FeedGenerator()
+ fg.title('some title')
+ fg.id('http://lernfunk.de/media/654322/1')
+ fe = fg.add_entry()
+ fe.id('http://lernfunk.de/media/654322/1')
+ fe.title('some title')
+ fe.content('content', type='CDATA')
+ result = fg.atom_str()
+ expected = b''
+ self.assertIn(expected, result)
+
+ def test_summary_html_type(self):
+ fg = FeedGenerator()
+ fg.title('some title')
+ fg.id('http://lernfunk.de/media/654322/1')
+ fe = fg.add_entry()
+ fe.id('http://lernfunk.de/media/654322/1')
+ fe.title('some title')
+ fe.link(href='http://lernfunk.de/media/654322/1')
+ fe.summary('
summary
', type='html')
+ result = fg.atom_str()
+ expected = b'<p>summary</p>'
+ self.assertIn(expected, result)
diff --git a/tests/test_extensions/__init__.py b/tests/test_extensions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_extensions/test_dc.py b/tests/test_extensions/test_dc.py
new file mode 100644
index 0000000..af7dd94
--- /dev/null
+++ b/tests/test_extensions/test_dc.py
@@ -0,0 +1,31 @@
+import unittest
+
+from feedgen.feed import FeedGenerator
+
+
+class TestExtensionDc(unittest.TestCase):
+
+ def setUp(self):
+ self.fg = FeedGenerator()
+ self.fg.load_extension('dc')
+ self.fg.title('title')
+ self.fg.link(href='http://example.com', rel='self')
+ self.fg.description('description')
+
+ def test_entryLoadExtension(self):
+ fe = self.fg.add_item()
+ try:
+ fe.load_extension('dc')
+ except ImportError:
+ pass # Extension already loaded
+
+ def test_elements(self):
+ for method in dir(self.fg.dc):
+ if method.startswith('dc_'):
+ m = getattr(self.fg.dc, method)
+ m(method)
+ self.assertEqual(m(), [method])
+
+ self.fg.id('123')
+ self.assertTrue(self.fg.atom_str())
+ self.assertTrue(self.fg.rss_str())
diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py
new file mode 100644
index 0000000..5de1f80
--- /dev/null
+++ b/tests/test_extensions/test_geo.py
@@ -0,0 +1,430 @@
+from itertools import chain
+import unittest
+import warnings
+
+from lxml import etree
+
+from feedgen.feed import FeedGenerator
+from feedgen.ext.geo_entry import GeoRSSPolygonInteriorWarning, GeoRSSGeometryError # noqa: E501
+
+
+class Geom(object):
+ """
+ Dummy geom to make testing easier
+
+ When we use the geo-interface we need a class with a `__geo_interface__`
+ property. Makes it easier for the other tests as well.
+
+ Ultimately this could be used to generate dummy geometries for testing
+ a wider variety of values (e.g. with the faker library, or the hypothesis
+ library)
+ """
+
+ def __init__(self, geom_type, coords):
+ self.geom_type = geom_type
+ self.coords = coords
+
+ def __str__(self):
+ if self.geom_type == 'Point':
+
+ coords = '{:f} {:f}'.format(
+ self.coords[1], # latitude is y
+ self.coords[0]
+ )
+ return coords
+
+ elif self.geom_type == 'LineString':
+
+ coords = ' '.join(
+ '{:f} {:f}'.format(vertex[1], vertex[0])
+ for vertex in
+ self.coords
+ )
+ return coords
+
+ elif self.geom_type == 'Polygon':
+
+ coords = ' '.join(
+ '{:f} {:f}'.format(vertex[1], vertex[0])
+ for vertex in
+ self.coords[0]
+ )
+ return coords
+
+ elif self.geom_type == 'Box':
+ # box not really supported by GeoJSON, but it's a handy cheat here
+ # for testing
+ coords = ' '.join(
+ '{:f} {:f}'.format(vertex[1], vertex[0])
+ for vertex in
+ self.coords
+ )
+ return coords[:2]
+
+ else:
+ return 'Not a supported geometry'
+
+ @property
+ def __geo_interface__(self):
+ return {
+ 'type': self.geom_type,
+ 'coordinates': self.coords
+ }
+
+
+class TestExtensionGeo(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ cls.point = Geom('Point', [-71.05, 42.36])
+ cls.line = Geom('LineString', [[-71.05, 42.36], [-71.15, 42.46]])
+ cls.polygon = Geom(
+ 'Polygon',
+ [[[-71.05, 42.36], [-71.15, 42.46], [-71.15, 42.36]]]
+ )
+ cls.box = Geom('Box', [[-71.05, 42.36], [-71.15, 42.46]])
+ cls.polygon_with_interior = Geom(
+ 'Polygon',
+ [
+ [ # exterior
+ [0, 0],
+ [0, 1],
+ [1, 1],
+ [1, 0],
+ [0, 0]
+ ],
+ [ # interior
+ [0.25, 0.25],
+ [0.25, 0.75],
+ [0.75, 0.75],
+ [0.75, 0.25],
+ [0.25, 0.25]
+ ]
+ ]
+ )
+
+ def setUp(self):
+ self.fg = FeedGenerator()
+ self.fg.load_extension('geo')
+ self.fg.title('title')
+ self.fg.link(href='http://example.com', rel='self')
+ self.fg.description('description')
+
+ def test_point(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.point(str(self.point))
+
+ self.assertEqual(fe.geo.point(), str(self.point))
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ point = root.xpath('/rss/channel/item/georss:point/text()',
+ namespaces=ns)
+ self.assertEqual(point, [str(self.point)])
+
+ def test_line(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.line(str(self.line))
+
+ self.assertEqual(fe.geo.line(), str(self.line))
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ line = root.xpath(
+ '/rss/channel/item/georss:line/text()',
+ namespaces=ns
+ )
+ self.assertEqual(line, [str(self.line)])
+
+ def test_polygon(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.polygon(str(self.polygon))
+
+ self.assertEqual(fe.geo.polygon(), str(self.polygon))
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ poly = root.xpath(
+ '/rss/channel/item/georss:polygon/text()',
+ namespaces=ns
+ )
+ self.assertEqual(poly, [str(self.polygon)])
+
+ def test_box(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.box(str(self.box))
+
+ self.assertEqual(fe.geo.box(), str(self.box))
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ box = root.xpath(
+ '/rss/channel/item/georss:box/text()',
+ namespaces=ns
+ )
+ self.assertEqual(box, [str(self.box)])
+
+ def test_featuretypetag(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.featuretypetag('city')
+
+ self.assertEqual(fe.geo.featuretypetag(), 'city')
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ featuretypetag = root.xpath(
+ '/rss/channel/item/georss:featuretypetag/text()',
+ namespaces=ns
+ )
+ self.assertEqual(featuretypetag, ['city'])
+
+ def test_relationshiptag(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.relationshiptag('is-centred-at')
+
+ self.assertEqual(fe.geo.relationshiptag(), 'is-centred-at')
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ relationshiptag = root.xpath(
+ '/rss/channel/item/georss:relationshiptag/text()',
+ namespaces=ns
+ )
+ self.assertEqual(relationshiptag, ['is-centred-at'])
+
+ def test_featurename(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.featurename('Footscray')
+
+ self.assertEqual(fe.geo.featurename(), 'Footscray')
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ featurename = root.xpath(
+ '/rss/channel/item/georss:featurename/text()',
+ namespaces=ns
+ )
+ self.assertEqual(featurename, ['Footscray'])
+
+ def test_elev(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.elev(100.3)
+
+ self.assertEqual(fe.geo.elev(), 100.3)
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ elev = root.xpath(
+ '/rss/channel/item/georss:elev/text()',
+ namespaces=ns
+ )
+ self.assertEqual(elev, ['100.3'])
+
+ def test_elev_fails_nonnumeric(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+
+ with self.assertRaises(ValueError):
+ fe.geo.elev('100.3')
+
+ def test_floor(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.floor(4)
+
+ self.assertEqual(fe.geo.floor(), 4)
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ floor = root.xpath(
+ '/rss/channel/item/georss:floor/text()',
+ namespaces=ns
+ )
+ self.assertEqual(floor, ['4'])
+
+ def test_floor_fails_nonint(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+
+ with self.assertRaises(ValueError):
+ fe.geo.floor(100.3)
+
+ with self.assertRaises(ValueError):
+ fe.geo.floor('4')
+
+ def test_radius(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.radius(100.3)
+
+ self.assertEqual(fe.geo.radius(), 100.3)
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ radius = root.xpath(
+ '/rss/channel/item/georss:radius/text()',
+ namespaces=ns
+ )
+ self.assertEqual(radius, ['100.3'])
+
+ def test_radius_fails_nonnumeric(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+
+ with self.assertRaises(ValueError):
+ fe.geo.radius('100.3')
+
+ def test_geom_from_geointerface_point(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.geom_from_geo_interface(self.point)
+
+ self.assertEqual(fe.geo.point(), str(self.point))
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ point = root.xpath('/rss/channel/item/georss:point/text()',
+ namespaces=ns)
+ self.assertEqual(point, [str(self.point)])
+
+ coords = [float(c) for c in point[0].split()]
+
+ try:
+ self.assertCountEqual(
+ coords,
+ self.point.coords
+ )
+ except AttributeError: # was assertItemsEqual in Python 2.7
+ self.assertItemsEqual(
+ coords,
+ self.point.coords
+ )
+
+ def test_geom_from_geointerface_line(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.geom_from_geo_interface(self.line)
+
+ self.assertEqual(fe.geo.line(), str(self.line))
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ line = root.xpath('/rss/channel/item/georss:line/text()',
+ namespaces=ns)
+ self.assertEqual(line, [str(self.line)])
+
+ coords = [float(c) for c in line[0].split()]
+
+ try:
+ self.assertCountEqual(
+ coords,
+ list(chain.from_iterable(self.line.coords))
+ )
+ except AttributeError: # was assertItemsEqual in Python 2.7
+ self.assertItemsEqual(
+ coords,
+ list(chain.from_iterable(self.line.coords))
+ )
+
+ def test_geom_from_geointerface_poly(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.geo.geom_from_geo_interface(self.polygon)
+
+ self.assertEqual(fe.geo.polygon(), str(self.polygon))
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ poly = root.xpath('/rss/channel/item/georss:polygon/text()',
+ namespaces=ns)
+ self.assertEqual(poly, [str(self.polygon)])
+
+ coords = [float(c) for c in poly[0].split()]
+
+ try:
+ self.assertCountEqual(
+ coords,
+ list(chain.from_iterable(self.polygon.coords[0]))
+ )
+ except AttributeError: # was assertItemsEqual in Python 2.7
+ self.assertItemsEqual(
+ coords,
+ list(chain.from_iterable(self.polygon.coords[0]))
+ )
+
+ def test_geom_from_geointerface_fail_other_geom(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+
+ with self.assertRaises(GeoRSSGeometryError):
+ fe.geo.geom_from_geo_interface(self.box)
+
+ def test_geom_from_geointerface_fail_requires_geo_interface(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+
+ with self.assertRaises(AttributeError):
+ fe.geo.geom_from_geo_interface(str(self.box))
+
+ def test_geom_from_geointerface_warn_poly_interior(self):
+ """
+ Test complex polygons warn as expected. Taken from
+
+ https://stackoverflow.com/a/3892301/379566 and
+ https://docs.python.org/2.7/library/warnings.html#testing-warnings
+ """
+ fe = self.fg.add_item()
+ fe.title('y')
+
+ with warnings.catch_warnings(record=True) as w:
+ # Cause all warnings to always be triggered.
+ warnings.simplefilter("always")
+ # Trigger a warning.
+ fe.geo.geom_from_geo_interface(self.polygon_with_interior)
+ # Verify some things
+ self.assertEqual(len(w), 1)
+ self.assertTrue(issubclass(w[-1].category,
+ GeoRSSPolygonInteriorWarning))
+
+ self.assertEqual(fe.geo.polygon(), str(self.polygon_with_interior))
+
+ # Check that we have the item in the resulting XML
+ ns = {'georss': 'http://www.georss.org/georss'}
+ root = etree.fromstring(self.fg.rss_str())
+ poly = root.xpath('/rss/channel/item/georss:polygon/text()',
+ namespaces=ns)
+ self.assertEqual(poly, [str(self.polygon_with_interior)])
+
+ coords = [float(c) for c in poly[0].split()]
+
+ try:
+ self.assertCountEqual(
+ coords,
+ list(chain.from_iterable(self.polygon_with_interior.coords[0]))
+ )
+ except AttributeError: # was assertItemsEqual in Python 2.7
+ self.assertItemsEqual(
+ coords,
+ list(chain.from_iterable(self.polygon_with_interior.coords[0]))
+ )
diff --git a/tests/test_extensions/test_media.py b/tests/test_extensions/test_media.py
new file mode 100644
index 0000000..1f5a349
--- /dev/null
+++ b/tests/test_extensions/test_media.py
@@ -0,0 +1,83 @@
+import unittest
+
+from lxml import etree
+
+from feedgen.feed import FeedGenerator
+
+
+class TestExtensionMedia(unittest.TestCase):
+
+ def setUp(self):
+ self.fg = FeedGenerator()
+ self.fg.load_extension('media')
+ self.fg.id('id')
+ self.fg.title('title')
+ self.fg.link(href='http://example.com', rel='self')
+ self.fg.description('description')
+
+ def test_media_content(self):
+ fe = self.fg.add_item()
+ fe.id('id')
+ fe.title('title')
+ fe.content('content')
+ fe.media.content(url='file1.xy')
+ fe.media.content(url='file2.xy')
+ fe.media.content(url='file1.xy', group=2)
+ fe.media.content(url='file2.xy', group=2)
+ fe.media.content(url='file.xy', group=None)
+
+ ns = {'media': 'http://search.yahoo.com/mrss/',
+ 'a': 'http://www.w3.org/2005/Atom'}
+ # Check that we have the item in the resulting RSS
+ root = etree.fromstring(self.fg.rss_str())
+ url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url',
+ namespaces=ns)
+ self.assertEqual(url, ['file1.xy', 'file1.xy'])
+
+ # There is one without a group
+ url = root.xpath('/rss/channel/item/media:content[1]/@url',
+ namespaces=ns)
+ self.assertEqual(url, ['file.xy'])
+
+ # Check that we have the item in the resulting Atom feed
+ root = etree.fromstring(self.fg.atom_str())
+ url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url',
+ namespaces=ns)
+ self.assertEqual(url, ['file1.xy', 'file1.xy'])
+
+ fe.media.content(content=[], replace=True)
+ self.assertEqual(fe.media.content(), [])
+
+ def test_media_thumbnail(self):
+ fe = self.fg.add_item()
+ fe.id('id')
+ fe.title('title')
+ fe.content('content')
+ fe.media.thumbnail(url='file1.xy')
+ fe.media.thumbnail(url='file2.xy')
+ fe.media.thumbnail(url='file1.xy', group=2)
+ fe.media.thumbnail(url='file2.xy', group=2)
+ fe.media.thumbnail(url='file.xy', group=None)
+
+ ns = {'media': 'http://search.yahoo.com/mrss/',
+ 'a': 'http://www.w3.org/2005/Atom'}
+ # Check that we have the item in the resulting RSS
+ root = etree.fromstring(self.fg.rss_str())
+ url = root.xpath(
+ '/rss/channel/item/media:group/media:thumbnail[1]/@url',
+ namespaces=ns)
+ self.assertEqual(url, ['file1.xy', 'file1.xy'])
+
+ # There is one without a group
+ url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url',
+ namespaces=ns)
+ self.assertEqual(url, ['file.xy'])
+
+ # Check that we have the item in the resulting Atom feed
+ root = etree.fromstring(self.fg.atom_str())
+ url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url',
+ namespaces=ns)
+ self.assertEqual(url, ['file1.xy', 'file1.xy'])
+
+ fe.media.thumbnail(thumbnail=[], replace=True)
+ self.assertEqual(fe.media.thumbnail(), [])
diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py
new file mode 100644
index 0000000..84c8c96
--- /dev/null
+++ b/tests/test_extensions/test_podcast.py
@@ -0,0 +1,106 @@
+import unittest
+
+from lxml import etree
+
+from feedgen.feed import FeedGenerator
+
+
+class TestExtensionPodcast(unittest.TestCase):
+
+ def setUp(self):
+ self.fg = FeedGenerator()
+ self.fg.load_extension('podcast')
+ self.fg.title('title')
+ self.fg.link(href='http://example.com', rel='self')
+ self.fg.description('description')
+
+ def test_category_new(self):
+ self.fg.podcast.itunes_category([{'cat': 'Technology',
+ 'sub': 'Podcasting'}])
+ self.fg.podcast.itunes_explicit('no')
+ self.fg.podcast.itunes_complete('no')
+ self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss')
+ self.fg.podcast.itunes_owner('John Doe', 'john@example.com')
+ ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'}
+ root = etree.fromstring(self.fg.rss_str())
+ cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns)
+ scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text',
+ namespaces=ns)
+ self.assertEqual(cat[0], 'Technology')
+ self.assertEqual(scat[0], 'Podcasting')
+
+ def test_category(self):
+ self.fg.podcast.itunes_category('Technology', 'Podcasting')
+ self.fg.podcast.itunes_explicit('no')
+ self.fg.podcast.itunes_complete('no')
+ self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss')
+ self.fg.podcast.itunes_owner('John Doe', 'john@example.com')
+ ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'}
+ root = etree.fromstring(self.fg.rss_str())
+ cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns)
+ scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text',
+ namespaces=ns)
+ self.assertEqual(cat[0], 'Technology')
+ self.assertEqual(scat[0], 'Podcasting')
+
+ def test_podcastItems(self):
+ fg = self.fg
+ fg.podcast.itunes_author('Lars Kiesow')
+ fg.podcast.itunes_block('x')
+ fg.podcast.itunes_complete(False)
+ fg.podcast.itunes_explicit('no')
+ fg.podcast.itunes_image('x.png')
+ fg.podcast.itunes_subtitle('x')
+ fg.podcast.itunes_summary('x')
+ fg.podcast.itunes_type('episodic')
+ self.assertEqual(fg.podcast.itunes_author(), 'Lars Kiesow')
+ self.assertEqual(fg.podcast.itunes_block(), 'x')
+ self.assertEqual(fg.podcast.itunes_complete(), 'no')
+ self.assertEqual(fg.podcast.itunes_explicit(), 'no')
+ self.assertEqual(fg.podcast.itunes_image(), 'x.png')
+ self.assertEqual(fg.podcast.itunes_subtitle(), 'x')
+ self.assertEqual(fg.podcast.itunes_summary(), 'x')
+ self.assertEqual(fg.podcast.itunes_type(), 'episodic')
+
+ # Check that we have the item in the resulting XML
+ ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'}
+ root = etree.fromstring(self.fg.rss_str())
+ author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns)
+ self.assertEqual(author, ['Lars Kiesow'])
+
+ def test_podcastEntryItems(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.podcast.itunes_author('Lars Kiesow')
+ fe.podcast.itunes_block('x')
+ fe.podcast.itunes_duration('00:01:30')
+ fe.podcast.itunes_explicit('no')
+ fe.podcast.itunes_image('x.png')
+ fe.podcast.itunes_is_closed_captioned('yes')
+ fe.podcast.itunes_order(1)
+ fe.podcast.itunes_subtitle('x')
+ fe.podcast.itunes_summary('x')
+ fe.podcast.itunes_season(1)
+ fe.podcast.itunes_episode(1)
+ fe.podcast.itunes_title('Podcast Title')
+ fe.podcast.itunes_episode_type('full')
+ self.assertEqual(fe.podcast.itunes_author(), 'Lars Kiesow')
+ self.assertEqual(fe.podcast.itunes_block(), 'x')
+ self.assertEqual(fe.podcast.itunes_duration(), '00:01:30')
+ self.assertEqual(fe.podcast.itunes_explicit(), 'no')
+ self.assertEqual(fe.podcast.itunes_image(), 'x.png')
+ self.assertTrue(fe.podcast.itunes_is_closed_captioned())
+ self.assertEqual(fe.podcast.itunes_order(), 1)
+ self.assertEqual(fe.podcast.itunes_subtitle(), 'x')
+ self.assertEqual(fe.podcast.itunes_summary(), 'x')
+ self.assertEqual(fe.podcast.itunes_season(), 1)
+ self.assertEqual(fe.podcast.itunes_episode(), 1)
+ self.assertEqual(fe.podcast.itunes_title(), 'Podcast Title')
+ self.assertEqual(fe.podcast.itunes_episode_type(), 'full')
+
+ # Check that we have the item in the resulting XML
+ ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'}
+ root = etree.fromstring(self.fg.rss_str())
+ author = root.xpath('/rss/channel/item/itunes:author/text()',
+ namespaces=ns)
+ self.assertEqual(author, ['Lars Kiesow'])
diff --git a/tests/test_extensions/test_syndication.py b/tests/test_extensions/test_syndication.py
new file mode 100644
index 0000000..029e100
--- /dev/null
+++ b/tests/test_extensions/test_syndication.py
@@ -0,0 +1,40 @@
+import unittest
+
+from lxml import etree
+
+from feedgen.feed import FeedGenerator
+
+
+class TestExtensionSyndication(unittest.TestCase):
+
+ SYN_NS = {'sy': 'http://purl.org/rss/1.0/modules/syndication/'}
+
+ def setUp(self):
+ self.fg = FeedGenerator()
+ self.fg.load_extension('syndication')
+ self.fg.title('title')
+ self.fg.link(href='http://example.com', rel='self')
+ self.fg.description('description')
+
+ def test_update_period(self):
+ for period_type in ('hourly', 'daily', 'weekly', 'monthly', 'yearly'):
+ self.fg.syndication.update_period(period_type)
+ root = etree.fromstring(self.fg.rss_str())
+ a = root.xpath('/rss/channel/sy:UpdatePeriod',
+ namespaces=self.SYN_NS)
+ self.assertEqual(a[0].text, period_type)
+
+ def test_update_frequency(self):
+ for frequency in (1, 100, 2000, 100000):
+ self.fg.syndication.update_frequency(frequency)
+ root = etree.fromstring(self.fg.rss_str())
+ a = root.xpath('/rss/channel/sy:UpdateFrequency',
+ namespaces=self.SYN_NS)
+ self.assertEqual(a[0].text, str(frequency))
+
+ def test_update_base(self):
+ base = '2000-01-01T12:00+00:00'
+ self.fg.syndication.update_base(base)
+ root = etree.fromstring(self.fg.rss_str())
+ a = root.xpath('/rss/channel/sy:UpdateBase', namespaces=self.SYN_NS)
+ self.assertEqual(a[0].text, base)
diff --git a/tests/test_extensions/test_torrent.py b/tests/test_extensions/test_torrent.py
new file mode 100644
index 0000000..230ec1a
--- /dev/null
+++ b/tests/test_extensions/test_torrent.py
@@ -0,0 +1,38 @@
+import unittest
+
+from lxml import etree
+
+from feedgen.feed import FeedGenerator
+
+
+class TestExtensionTorrent(unittest.TestCase):
+
+ def setUp(self):
+ self.fg = FeedGenerator()
+ self.fg.load_extension('torrent')
+ self.fg.title('title')
+ self.fg.link(href='http://example.com', rel='self')
+ self.fg.description('description')
+
+ def test_podcastEntryItems(self):
+ fe = self.fg.add_item()
+ fe.title('y')
+ fe.torrent.filename('file.xy')
+ fe.torrent.infohash('123')
+ fe.torrent.contentlength('23')
+ fe.torrent.seeds('1')
+ fe.torrent.peers('2')
+ fe.torrent.verified('1')
+ self.assertEqual(fe.torrent.filename(), 'file.xy')
+ self.assertEqual(fe.torrent.infohash(), '123')
+ self.assertEqual(fe.torrent.contentlength(), '23')
+ self.assertEqual(fe.torrent.seeds(), '1')
+ self.assertEqual(fe.torrent.peers(), '2')
+ self.assertEqual(fe.torrent.verified(), '1')
+
+ # Check that we have the item in the resulting XML
+ ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'}
+ root = etree.fromstring(self.fg.rss_str())
+ filename = root.xpath('/rss/channel/item/torrent:filename/text()',
+ namespaces=ns)
+ self.assertEqual(filename, ['file.xy'])
diff --git a/tests/test_feed.py b/tests/test_feed.py
new file mode 100644
index 0000000..d09014d
--- /dev/null
+++ b/tests/test_feed.py
@@ -0,0 +1,401 @@
+# -*- 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 os
+import tempfile
+import unittest
+
+from lxml import etree
+
+from feedgen.ext.dc import DcEntryExtension, DcExtension
+from feedgen.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)
+ fg.updated('2017-02-05 13:26:58+01:00')
+ fg.pubDate('2017-02-05 13:26:58+01:00')
+ fg.generator('python-feedgen', 'x', uri='http://github.com/lkie...')
+ fg.image(url=self.logo,
+ title=self.title,
+ link=self.link2Href,
+ width='123',
+ height='123',
+ description='Example Inage')
+
+ self.fg = fg
+
+ def test_baseFeed(self):
+ fg = self.fg
+
+ self.assertEqual(fg.id(), self.feedId)
+ self.assertEqual(fg.title(), self.title)
+
+ self.assertEqual(fg.author()[0]['name'], self.authorName)
+ self.assertEqual(fg.author()[0]['email'], self.authorMail)
+
+ self.assertEqual(fg.link()[0]['href'], self.linkHref)
+ self.assertEqual(fg.link()[0]['rel'], self.linkRel)
+
+ self.assertEqual(fg.logo(), self.logo)
+ self.assertEqual(fg.subtitle(), self.subtitle)
+
+ self.assertEqual(fg.link()[1]['href'], self.link2Href)
+ self.assertEqual(fg.link()[1]['rel'], self.link2Rel)
+
+ self.assertEqual(fg.language(), self.language)
+
+ def test_atomFeedFile(self):
+ fg = self.fg
+ fh, filename = tempfile.mkstemp()
+ fg.atom_file(filename=filename, pretty=True, xml_declaration=False)
+
+ with open(filename, "r") as myfile:
+ atomString = myfile.read().replace('\n', '')
+
+ self.checkAtomString(atomString)
+ os.close(fh)
+ os.remove(filename)
+
+ def test_atomFeedString(self):
+ 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
+ self.assertEqual(len(links), len(feed_links))
+ while idx < len(values_for_rel):
+ self.assertEqual(feed_links[idx].get('href'), links[idx]['href'])
+ self.assertEqual(feed_links[idx].get('rel'), links[idx]['rel'])
+ idx += 1
+
+ def test_rel_values_for_rss(self):
+ values_for_rel = [
+ 'about', 'alternate', 'appendix', 'archives', 'author', 'bookmark',
+ 'canonical', 'chapter', 'collection', 'contents', 'copyright',
+ 'create-form', 'current', 'derivedfrom', 'describedby',
+ 'describes', 'disclosure', 'duplicate', 'edit', 'edit-form',
+ 'edit-media', 'enclosure', 'first', 'glossary', 'help', 'hosts',
+ 'hub', 'icon', 'index', 'item', 'last', 'latest-version',
+ 'license', 'lrdd', 'memento', 'monitor', 'monitor-group', 'next',
+ 'next-archive', 'nofollow', 'noreferrer', 'original', 'payment',
+ 'predecessor-version', 'prefetch', 'prev', 'preview', 'previous',
+ 'prev-archive', 'privacy-policy', 'profile', 'related', 'replies',
+ 'search', 'section', 'self', 'service', 'start', 'stylesheet',
+ 'subsection', 'successor-version', 'tag', 'terms-of-service',
+ 'timegate', 'timemap', 'type', 'up', 'version-history', 'via',
+ 'working-copy', 'working-copy-of']
+ links = [{'href': '%s/%s' % (self.linkHref,
+ val.replace('-', '_')), 'rel': val}
+ for val in values_for_rel]
+ fg = self.fg
+ fg.link(links, replace=True)
+ rssString = fg.rss_str(pretty=True, xml_declaration=False)
+ feed = etree.fromstring(rssString)
+ channel = feed.find("channel")
+ nsAtom = self.nsAtom
+
+ atom_links = channel.findall("{%s}link" % nsAtom)
+ # rss feed only implements atom's 'self' link
+ self.assertEqual(len(atom_links), 1)
+ self.assertEqual(atom_links[0].get('href'),
+ '%s/%s' % (self.linkHref, 'self'))
+ self.assertEqual(atom_links[0].get('rel'), 'self')
+
+ rss_links = channel.findall('link')
+ # RSS only needs one URL. We use the first link for RSS:
+ self.assertEqual(len(rss_links), 1)
+ self.assertEqual(
+ rss_links[0].text,
+ '%s/%s' % (self.linkHref, 'working-copy-of'.replace('-', '_')))
+
+ def checkAtomString(self, atomString):
+
+ feed = etree.fromstring(atomString)
+
+ nsAtom = "{" + self.nsAtom + "}"
+
+ print(nsAtom)
+ print(f"{nsAtom}title")
+ testcases = [
+ (
+ feed.find(f"{nsAtom}title").text,
+ self.title
+ ), (
+ feed.find(f"{nsAtom}id").text,
+ self.feedId
+ ), (
+ feed.find(f"{nsAtom}category").get('term'),
+ self.categoryTerm
+ ), (
+ feed.find(f"{nsAtom}category").get('label'),
+ self.categoryLabel
+ ), (
+ feed.find(f"{nsAtom}author").find(f"{nsAtom}name").text,
+ self.authorName
+ ), (
+ feed.find(f"{nsAtom}author").find(f"{nsAtom}email").text,
+ self.authorMail
+ ), (
+ feed.findall(f"{nsAtom}link")[0].get('href'),
+ self.linkHref
+ ), (
+ feed.findall(f"{nsAtom}link")[0].get('rel'),
+ self.linkRel
+ ), (
+ feed.findall(f"{nsAtom}link")[1].get('href'),
+ self.link2Href
+ ), (
+ feed.findall(f"{nsAtom}link")[1].get('rel'),
+ self.link2Rel
+ ), (
+ feed.find(f"{nsAtom}logo").text,
+ self.logo
+ ), (
+ feed.find(f"{nsAtom}icon").text,
+ self.icon
+ ), (
+ feed.find(f"{nsAtom}subtitle").text,
+ self.subtitle
+ ), (
+ feed.find(f"{nsAtom}contributor").find(f"{nsAtom}name").text,
+ self.contributor['name']
+ ), (
+ feed.find(f"{nsAtom}contributor").find(f"{nsAtom}email").text,
+ self.contributor['email']
+ ), (
+ feed.find(f"{nsAtom}contributor").find(f"{nsAtom}uri").text,
+ self.contributor['uri']
+ ), (
+ feed.find(f"{nsAtom}rights").text,
+ self.copyright
+ )]
+ for actual, expected in testcases:
+ self.assertEqual(actual, expected)
+
+ self.assertIsNot(
+ feed.find(f"{nsAtom}updated").text,
+ None)
+
+ def test_rssFeedFile(self):
+ fg = self.fg
+ _, filename = tempfile.mkstemp()
+ fg.rss_file(filename=filename, pretty=True, xml_declaration=False)
+
+ with open(filename, "r") as myfile:
+ rssString = myfile.read().replace('\n', '')
+
+ self.checkRssString(rssString)
+
+ def test_rssFeedString(self):
+ fg = self.fg
+ rssString = fg.rss_str(pretty=True, xml_declaration=False)
+ self.checkRssString(rssString)
+
+ def test_loadPodcastExtension(self):
+ fg = self.fg
+ fg.add_entry()
+ fg.load_extension('podcast', atom=True, rss=True)
+ fg.add_entry()
+
+ def test_loadDcExtension(self):
+ fg = self.fg
+ fg.add_entry()
+ fg.load_extension('dc', atom=True, rss=True)
+ fg.add_entry()
+
+ def test_extensionAlreadyLoaded(self):
+ fg = self.fg
+ fg.load_extension('dc', atom=True, rss=True)
+ with self.assertRaises(ImportError):
+ fg.load_extension('dc')
+
+ def test_registerCustomExtension(self):
+ fg = self.fg
+ fg.add_entry()
+ fg.register_extension('dc', DcExtension, DcEntryExtension)
+ fg.add_entry()
+
+ def checkRssString(self, rssString):
+
+ feed = etree.fromstring(rssString)
+ nsAtom = self.nsAtom
+
+ ch = feed.find("channel")
+ self.assertIsNot(ch, None)
+
+ self.assertEqual(ch.find("title").text,
+ self.title)
+ self.assertEqual(ch.find("description").text,
+ self.subtitle)
+ self.assertIsNot(ch.find("lastBuildDate").text,
+ None)
+ self.assertEqual(ch.find("docs").text,
+ "http://www.rssboard.org/rss-specification")
+ self.assertEqual(ch.find("generator").text,
+ "python-feedgen")
+ self.assertEqual(ch.findall("{%s}link" % nsAtom)[0].get('href'),
+ self.link2Href)
+ self.assertEqual(ch.findall("{%s}link" % nsAtom)[0].get('rel'),
+ self.link2Rel)
+ self.assertEqual(ch.find("image").find("url").text,
+ self.logo)
+ self.assertEqual(ch.find("image").find("title").text,
+ self.title)
+ self.assertEqual(ch.find("image").find("link").text,
+ self.link2Href)
+ self.assertEqual(ch.find("category").text,
+ self.categoryLabel)
+ self.assertEqual(ch.find("cloud").get('domain'),
+ self.cloudDomain)
+ self.assertEqual(ch.find("cloud").get('port'),
+ self.cloudPort)
+ self.assertEqual(ch.find("cloud").get('path'),
+ self.cloudPath)
+ self.assertEqual(ch.find("cloud").get('registerProcedure'),
+ self.cloudRegisterProcedure)
+ self.assertEqual(ch.find("cloud").get('protocol'),
+ self.cloudProtocol)
+ self.assertEqual(ch.find("copyright").text,
+ self.copyright)
+ self.assertEqual(ch.find("docs").text,
+ self.docs)
+ self.assertEqual(ch.find("managingEditor").text,
+ self.managingEditor)
+ self.assertEqual(ch.find("rating").text,
+ self.rating)
+ self.assertEqual(ch.find("skipDays").find("day").text,
+ self.skipDays)
+ self.assertEqual(int(ch.find("skipHours").find("hour").text),
+ self.skipHours)
+ self.assertEqual(ch.find("textInput").get('title'),
+ self.textInputTitle)
+ self.assertEqual(ch.find("textInput").get('description'),
+ self.textInputDescription)
+ self.assertEqual(ch.find("textInput").get('name'),
+ self.textInputName)
+ self.assertEqual(ch.find("textInput").get('link'),
+ self.textInputLink)
+ self.assertEqual(int(ch.find("ttl").text),
+ self.ttl)
+ self.assertEqual(ch.find("webMaster").text,
+ self.webMaster)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 0000000..af7e981
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+
+'''
+Tests for feedgen main
+'''
+
+import os
+import sys
+import tempfile
+import unittest
+
+from feedgen import __main__
+
+
+class TestSequenceFunctions(unittest.TestCase):
+
+ def test_usage(self):
+ sys.argv = ['feedgen']
+ with self.assertRaises(SystemExit) as e:
+ __main__.main()
+ self.assertEqual(e.exception.code, None)
+
+ def test_feed(self):
+ for ftype in 'rss', 'atom', 'podcast', 'torrent', 'dc.rss', \
+ 'dc.atom', 'syndication.rss', 'syndication.atom':
+ sys.argv = ['feedgen', ftype]
+ __main__.main()
+
+ def test_file(self):
+ for extemsion in '.atom', '.rss':
+ fh, filename = tempfile.mkstemp(extemsion)
+ sys.argv = ['feedgen', filename]
+ try:
+ __main__.main()
+ finally:
+ os.close(fh)
+ os.remove(filename)