summaryrefslogtreecommitdiffstats
path: root/feedgen/ext
diff options
context:
space:
mode:
Diffstat (limited to 'feedgen/ext')
-rw-r--r--feedgen/ext/__init__.py6
-rw-r--r--feedgen/ext/base.py44
-rw-r--r--feedgen/ext/dc.py407
-rw-r--r--feedgen/ext/geo.py21
-rw-r--r--feedgen/ext/geo_entry.py329
-rw-r--r--feedgen/ext/media.py183
-rw-r--r--feedgen/ext/podcast.py388
-rw-r--r--feedgen/ext/podcast_entry.py321
-rw-r--r--feedgen/ext/syndication.py60
-rw-r--r--feedgen/ext/torrent.py126
10 files changed, 1885 insertions, 0 deletions
diff --git a/feedgen/ext/__init__.py b/feedgen/ext/__init__.py
new file mode 100644
index 0000000..0e2b628
--- /dev/null
+++ b/feedgen/ext/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+"""
+ ===========
+ feedgen.ext
+ ===========
+"""
diff --git a/feedgen/ext/base.py b/feedgen/ext/base.py
new file mode 100644
index 0000000..b94826d
--- /dev/null
+++ b/feedgen/ext/base.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+'''
+ feedgen.ext.base
+ ~~~~~~~~~~~~~~~~
+
+ Basic FeedGenerator extension which does nothing but provides all necessary
+ methods.
+
+ :copyright: 2013, Lars Kiesow <lkiesow@uos.de>
+
+ :license: FreeBSD and LGPL, see license.* for more details.
+'''
+
+
+class BaseExtension(object):
+ '''Basic FeedGenerator extension.
+ '''
+ def extend_ns(self):
+ '''Returns a dict that will be used in the namespace map for the feed.
+ '''
+ return dict()
+
+ def extend_rss(self, feed):
+ '''Extend a RSS feed xml structure containing all previously set
+ fields.
+
+ :param feed: The feed xml root element.
+ :returns: The feed root element.
+ '''
+ return feed
+
+ def extend_atom(self, feed):
+ '''Extend an ATOM feed xml structure containing all previously set
+ fields.
+
+ :param feed: The feed xml root element.
+ :returns: The feed root element.
+ '''
+ return feed
+
+
+class BaseEntryExtension(BaseExtension):
+ '''Basic FeedEntry extension.
+ '''
diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py
new file mode 100644
index 0000000..466c66d
--- /dev/null
+++ b/feedgen/ext/dc.py
@@ -0,0 +1,407 @@
+# -*- coding: utf-8 -*-
+'''
+ feedgen.ext.dc
+ ~~~~~~~~~~~~~~~~~~~
+
+ Extends the FeedGenerator to add Dubline Core Elements to the feeds.
+
+ Descriptions partly taken from
+ http://dublincore.org/documents/dcmi-terms/#elements-coverage
+
+ :copyright: 2013-2017, Lars Kiesow <lkiesow@uos.de>
+
+ :license: FreeBSD and LGPL, see license.* for more details.
+'''
+
+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_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.
+ '''
+
+
+class DcEntryExtension(DcBaseExtension):
+ '''Dublin Core Elements extension for podcasts.
+ '''
+ def extend_atom(self, entry):
+ '''Add dc elements to an atom item. Alters the item itself.
+
+ :param entry: An atom entry element.
+ :returns: The entry element.
+ '''
+ self._extend_xml(entry)
+ return entry
+
+ def extend_rss(self, item):
+ '''Add dc elements to a RSS item. Alters the item itself.
+
+ :param item: A RSS item element.
+ :returns: The item element.
+ '''
+ self._extend_xml(item)
+ return item
diff --git a/feedgen/ext/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 <bob.breznak@gmail.com>
+
+ :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 <bob.breznak@gmail.com>
+
+ :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 <lkiesow@uos.de>
+
+ :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
+
+ <media:content> is a sub-element of either <item> or <media:group>.
+ Media objects that are not the same content should not be included in
+ the same <media:group> 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
new file mode 100644
index 0000000..c535392
--- /dev/null
+++ b/feedgen/ext/podcast.py
@@ -0,0 +1,388 @@
+# -*- coding: utf-8 -*-
+'''
+ feedgen.ext.podcast
+ ~~~~~~~~~~~~~~~~~~~
+
+ Extends the FeedGenerator to produce podcasts.
+
+ :copyright: 2013, Lars Kiesow <lkiesow@uos.de>
+
+ :license: FreeBSD and LGPL, see license.* for more details.
+'''
+
+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
+ 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 <author> tag. If <itunes:author> is not present at the
+ feed level, iTunes will use the contents of <managingEditor>.
+
+ :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::
+
+ <itunes:category text="Arts">
+ <itunes:category text="Design"/>
+ <itunes:category text="Food"/>
+ </itunes:category>
+
+
+ :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
+ <itunes:image> 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 <itunes:complete> tag is
+ present and has any other value (e.g. “no”), it will have no effect on
+ the podcast.
+
+ :param itunes_complete: If the podcast is complete.
+ :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
+ `<itunes:summary>` is not included, the contents of the <description>
+ 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
+ <itunes:episode> 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
new file mode 100644
index 0000000..2d60c2d
--- /dev/null
+++ b/feedgen/ext/podcast_entry.py
@@ -0,0 +1,321 @@
+# -*- coding: utf-8 -*-
+'''
+ feedgen.ext.podcast_entry
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Extends the feedgen to produce podcasts.
+
+ :copyright: 2013-2016, Lars Kiesow <lkiesow@uos.de>
+
+ :license: FreeBSD and LGPL, see license.* for more details.
+'''
+
+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
+ 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 <author> tag. If
+ <itunes:author> is not present at the feed level, iTunes will use the
+ contents of <managingEditor>.
+
+ :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
+ <itunes:image> 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 <item> 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 <item> to appear as the first episode in
+ the podcast, you would populate the <itunes:order> 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 <itunes:summary> is not included, the contents of the
+ <description> 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 <ksato9700@gmail.com>
+#
+
+'''
+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 <raspbeguy@hashtagueule.fr>
+
+ :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