From 9d6827deddf36dacbf67171dbd9920080491df9d Mon Sep 17 00:00:00 2001 From: Michael Scherer Date: Fri, 14 Dec 2018 14:50:36 -0500 Subject: [PATCH 01/47] Fix typo --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 92fb5f8..ab3e84b 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -367,7 +367,7 @@ def author(self, author=None, replace=False, **kwargs): return self.__atom_author def content(self, content=None, src=None, type=None): - '''Get or set the cntent of the entry which contains or links to the + '''Get or set the content of the entry which contains or links to the complete content of the entry. Content must be provided for ATOM entries if there is no alternate link, and should be provided if there is no summary. If the content is set (not linked) it will also set From 0871b48b38df45f2fc1f1fa5fb1ee545a9a35e78 Mon Sep 17 00:00:00 2001 From: Michael Scherer Date: Fri, 14 Dec 2018 15:17:32 -0500 Subject: [PATCH 02/47] Another typo --- feedgen/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 35e7229..624658e 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -99,7 +99,7 @@ both parameters is true which means that the extension would be used for both kinds of feeds. - **Example: Produceing a Podcast** + **Example: Producing a Podcast** One extension already provided is the podcast extension. A podcast is an RSS feed with some additional elements for ITunes. From 640231b23a1539f65aa6b09642a4f458d8931d49 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 14 Dec 2018 23:35:48 +0100 Subject: [PATCH 03/47] Drop Python 3.3 Python 3.3 reached end-of-life on 2017-09-29. Since it's now causing problems with Travis builds, this patch is dropping that version from builds. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 0061fdc..4c50301 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,9 +2,9 @@ language: python sudo: false +# https://devguide.python.org/#branchstatus python: - "2.7" - - "3.3" - "3.4" - "3.5" - "3.6" From 2beefb51266434de5293c1f1606b92bee7101f19 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 14 Dec 2018 23:55:36 +0100 Subject: [PATCH 04/47] Update Documentation This patch updates the wording on the main documentation page. --- readme.rst | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/readme.rst b/readme.rst index cb8e685..6558505 100644 --- a/readme.rst +++ b/readme.rst @@ -11,8 +11,9 @@ Feedgenerator :alt: Test Coverage Status -This module can be used to generate web feeds in both ATOM and RSS format. It -has support for extensions. Included is for example an extension to produce Podcasts. +This module can be used to generate web feeds in both ATOM and RSS format. It +has support for extensions. Included is for example an extension to produce +Podcasts. It is licensed under the terms of both, the FreeBSD license and the LGPLv3+. Choose the one which is more convenient for you. For more details have a look @@ -159,20 +160,17 @@ To produce a podcast simply load the `podcast` extension:: >>> fg.rss_str(pretty=True) >>> fg.rss_file('podcast.xml') -Of cause the extension has to be loaded for the FeedEntry objects as well but -this is done automatically by the FeedGenerator for every feed entry if the -extension is loaded for the whole feed. You can, however, load an extension for -a specific FeedEntry by calling `load_extension(...)` on that entry. But this -is a rather uncommon use. +If the FeedGenerator class is used to load an extension, it is automatically +loaded for every feed entry as well. You can, however, load an extension for a +specific FeedEntry only by calling `load_extension(...)` on that entry. -You can still produce a normal ATOM or RSS feed, even if you have loaded some -plugins by temporary disabling them during the feed generation. This can be -done by calling the generating method with the keyword argument `extensions` -set to `False`. +Even if extensions are loaded, they can be temporarily disabled during the feed +generation by calling the generating method with the keyword argument +`extensions` set to `False`. **Custom Extensions** -If you want to load custom extension which are not part of the feedgen Python +If you want to load custom extensions which are not part of the feedgen package, you can use the method `register_extension` instead. You can directly pass the classes for the feed and the entry extension to this method meaning that you can define them everywhere. From 3b557d364acd70332707acb1fd5d96831ae2a863 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 12:30:44 +1000 Subject: [PATCH 05/47] Add IDE and gesting artifacts to the gitignore file --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index b6f6775..af64cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea/ venv *.pyc *.pyo @@ -10,3 +11,7 @@ feedgen/tests/tmp_Rssfeed.xml tmp_Atomfeed.xml tmp_Rssfeed.xml + +# testing artifacts +.coverage +*.egg-info/ From 642862bb2b5e6f9bf8b3dad6b1459d2cb17b32d1 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 12:31:34 +1000 Subject: [PATCH 06/47] Update simple GeoRSS to complete the specification Originally the georss entry only contained a simple point specification. Update to include: - other geometries (line, polygon and box) - additional properties (featuretypetag, relationshiptag, featurename) - elevation (elev, floor) - radius (radius) This also includes basic type checking with a value error for the elev, floor and radius tags. --- feedgen/ext/geo_entry.py | 173 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 1 deletion(-) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 8c9dd15..4721e0a 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -9,6 +9,7 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' +import numbers from lxml import etree from feedgen.ext.base import BaseEntryExtension @@ -19,8 +20,24 @@ class GeoEntryExtension(BaseEntryExtension): ''' def __init__(self): - # Simple GeoRSS tag + '''Simple GeoRSS tag''' + # geometries self.__point = None + self.__line = None + self.__polygon = None + self.__box = None + + # additional properties + self.__featuretypetag = None + self.__relationshiptag = None + self.__featurename = None + + # elevation + self.__elev = None + self.__floor = None + + # radius + self.__radius = None def extend_file(self, entry): '''Add additional fields to an RSS item. @@ -34,6 +51,42 @@ def extend_file(self, entry): point = etree.SubElement(entry, '{%s}point' % GEO_NS) point.text = self.__point + if self.__line: + line = etree.SubElement(entry, '{%s}line' % GEO_NS) + line.text = self.__line + + if self.__polygon: + polygon = etree.SubElement(entry, '{%s}polygon' % GEO_NS) + polygon.text = self.__polygon + + if self.__box: + box = etree.SubElement(entry, '{%s}box' % GEO_NS) + box.text = self.__box + + if self.__featuretypetag: + featuretypetag = etree.SubElement(entry, '{%s}featuretypetag' % GEO_NS) + featuretypetag.text = self.__featuretypetag + + if self.__relationshiptag: + relationshiptag = etree.SubElement(entry, '{%s}relationshiptag' % GEO_NS) + relationshiptag.text = self.__relationshiptag + + if self.__featurename: + featurename = etree.SubElement(entry, '{%s}featurename' % GEO_NS) + featurename.text = self.__featurename + + if self.__elev: + elevation = etree.SubElement(entry, '{%s}elev' % GEO_NS) + elevation.text = self.__elev + + if self.__floor: + floor = etree.SubElement(entry, '{%s}floor' % GEO_NS) + floor.text = self.__floor + + if self.__radius: + radius = etree.SubElement(entry, '{%s}radius' % GEO_NS) + radius.text = self.__radius + return entry def extend_rss(self, entry): @@ -53,3 +106,121 @@ def point(self, point=None): self.__point = point return self.__point + + def line(self, line=None): + '''Get or set the georss:line of the entry + + :param point: The GeoRSS formatted line (i.e. "45.256 -110.45 46.46 -109.48 43.84 -109.86") + :return: The current georss:line of the entry + ''' + if line is not None: + self.__line = line + + return self.__line + + def polygon(self, polygon=None): + '''Get or set the georss:polygon of the entry + + :param polygon: The GeoRSS formatted polygon (i.e. "45.256 -110.45 46.46 -109.48 43.84 -109.86 45.256 -110.45") + :return: The current georss:polygon of the entry + ''' + if polygon is not None: + self.__polygon = polygon + + return self.__polygon + + def box(self, box=None): + ''' + Get or set the georss:box of the entry + + :param box: The GeoRSS formatted box (i.e. "42.943 -71.032 43.039 -69.856") + :return: The current georss:box of the entry + ''' + if box is not None: + self.__box = box + + return self.__box + + def featuretypetag(self, featuretypetag): + ''' + Get or set the georss:featuretypetag of the entry + + :param featuretypetag: The GeoRSS feaaturertyptag (e.g. "city") + :return: The current georss:featurertypetag + ''' + if featuretypetag is not None: + self.__featuretypetag = featuretypetag + + return self.__featuretypetag + + def relationshiptag(self, relationshiptag): + ''' + Get or set the georss:relationshiptag of the entry + + :param relationshiptag: The GeoRSS relationshiptag (e.g. "is-centred-at") + :return: the current georss:relationshiptag + ''' + if relationshiptag is not None: + self.__relationshiptag = relationshiptag + + return self.__relationshiptag + + def featurename(self, featurename): + ''' + Get or set the georss:featurename of the entry + + :param featuretypetag: The GeoRSS featurename (e.g. "city") + :return: the current georss:featurename + ''' + if featurename is not None: + self.__featurename = featurename + + return self.__featurename + + def elev(self, elev): + ''' + Get or set the georss:elev of the entry + + :param elev: The GeoRSS elevation (e.g. 100.3) + :type elev: numbers.Number + :return: the current georss:elev + ''' + if elev is not None: + if not isinstance(elev, numbers.Number): + raise ValueError("elev tag must be numeric: {}".format(elev)) + + self.__elev = elev + + return self.__elev + + def floor(self, floor): + ''' + Get or set the georss:floor of the entry + + :param floor: The GeoRSS floor (e.g. 4) + :type floor: int + :return: the current georss:floor + ''' + if floor is not None: + if not isinstance(floor, int): + raise ValueError("floor tag must be int: {}".format(floor)) + + self.__floor = floor + + return self.__floor + + def radius(self, radius): + ''' + Get or set the georss:radius of the entry + + :param radius: The GeoRSS radius (e.g. 100.3) + :type radius: numbers.Number + :return: the current georss:radius + ''' + if radius is not None: + if not isinstance(radius, numbers.Number): + raise ValueError("radius tag must be numeric: {}".format(radius)) + + self.__radius = radius + + return self.__radius From 8d413f576f9d1c26f74365d3eb87b4b310953874 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 13:38:39 +1000 Subject: [PATCH 07/47] Add a geom_from_geo_interface method for GeoRSS Entry A standard way for different geometry libraries in Python to be interoperable is a `__geo_interface__` for the geometry (see the specification: https://gist.github.com/sgillies/2217756). This includes the shapely library, geometries from QGIS, and geometries in Esri's arcpy libraries for ArcGIS desktop and ArcGIS pro. To make it easier to generate a georss entry a simple method which does the conversion (of the supported geometries only) and sets the appropriate geometry type. This includes a custom error for the geometry being incompatible and a custom warning for a polygon with interior holes. This is done to store the geometries on the exception / warning if required for debugging. --- feedgen/ext/geo_entry.py | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 4721e0a..e57fc81 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -10,11 +10,48 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' import numbers +import warnings from lxml import etree from feedgen.ext.base import BaseEntryExtension +class GeoRSSPolygonInteriorWarning(Warning): + """ + Simple placeholder for warning about ignored polygon interiors. + + Stores the original geom on a ``geom`` attribute (if required warnings are + raised as errors). + """ + + def __init__(self, geom, *args, **kwargs): + self.geom = geom + super(GeoRSSPolygonInteriorWarning, self).__init__(*args, **kwargs) + + def __str__(self): + return '{:d} interiors of polygon ignored'.format( + len(self.geom.__geo_interface__['coordinates']) - 1 # ignore exterior in count + ) + +class GeoRSSGeometryError(ValueError): + """ + Subclass of ValueError for a GeoRSS geometry error + + Only some geometries are supported in Simple GeoRSS, so if not raise an + error. Offending geometry is stored on the ``geom`` attribute. + + """ + + def __init__(self, geom, *args, **kwargs): + self.geom = geom + super(GeoRSSGeometryError, self).__init__(*args, **kwargs) + + def __str__(self): + return "Geometry of type '{}' not in Point, Linestring or Polygon".format( + self.geom.__geo_interface__['type'] + ) + + class GeoEntryExtension(BaseEntryExtension): '''FeedEntry extension for Simple GeoRSS. ''' @@ -224,3 +261,62 @@ def radius(self, radius): self.__radius = radius return self.__radius + + def geom_from_geo_interface(self, geom): + ''' + Generate a georss geometry from some Python object with a + ``__geo_interface__`` property (see the `geo_interface specification by + Sean Gillies`_geointerface ) + + Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be easily + converted to GeoRSS: + + - Point + - LineString + - Polygon (if there are holes / donuts in the polygons a warning will be + generaated + + Other GeoJson types will raise a ``ValueError``. + + .. note:: The geometry is assumed to be x, y as longitude, latitude in + the WGS84 projection. + + .. _geointerface: https://gist.github.com/sgillies/2217756 + .. _geojson: https://geojson.org/ + + :param geom: Geometry object with a __geo_interface__ property + :return: the formatted GeoRSS geometry + ''' + geojson = geom.__geo_interface__ + + if geojson['type'] not in ('Point', 'LineString', 'Polygon'): + raise GeoRSSGeometryError(geom) + + if geojson['type'] == 'Point': + + coords = '%f %f'.format( + geojson['coordinates'][1], # latitude is y + geojson['coordinates'][0] + ) + return self.point(coords) + + elif geojson['type'] == 'LineString': + + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + geojson['coordinates'] + ) + return self.line(coords) + + elif geojson['type'] == 'Polygon': + + if len(geojson['coordinates']) > 1: + warnings.warn(GeoRSSPolygonInteriorWarning(geom)) + + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + geojson['coordinates'][0] + ) + return self.polygon(coords) From d32487f2edad0120c7eb4efc86ff53caa78ef114 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 13:49:56 +1000 Subject: [PATCH 08/47] Separate extensions as their own files Give each extension its own test file. Primarily this is done to make it easier to add some fixtures and extend the geo tests. --- tests/test_extension.py | 300 ---------------------- tests/test_extensions/__init__.py | 0 tests/test_extensions/test_dc.py | 31 +++ tests/test_extensions/test_geo.py | 29 +++ tests/test_extensions/test_media.py | 83 ++++++ tests/test_extensions/test_podcast.py | 96 +++++++ tests/test_extensions/test_syndication.py | 40 +++ tests/test_extensions/test_torrent.py | 38 +++ 8 files changed, 317 insertions(+), 300 deletions(-) delete mode 100644 tests/test_extension.py create mode 100644 tests/test_extensions/__init__.py create mode 100644 tests/test_extensions/test_dc.py create mode 100644 tests/test_extensions/test_geo.py create mode 100644 tests/test_extensions/test_media.py create mode 100644 tests/test_extensions/test_podcast.py create mode 100644 tests/test_extensions/test_syndication.py create mode 100644 tests/test_extensions/test_torrent.py diff --git a/tests/test_extension.py b/tests/test_extension.py deleted file mode 100644 index db85c08..0000000 --- a/tests/test_extension.py +++ /dev/null @@ -1,300 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Tests for extensions -""" - -import unittest - -from lxml import etree - -from feedgen.feed import FeedGenerator - - -class TestExtensionSyndication(unittest.TestCase): - - SYN_NS = {'sy': 'http://purl.org/rss/1.0/modules/syndication/'} - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('syndication') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_update_period(self): - for period_type in ('hourly', 'daily', 'weekly', 'monthly', 'yearly'): - self.fg.syndication.update_period(period_type) - root = etree.fromstring(self.fg.rss_str()) - a = root.xpath('/rss/channel/sy:UpdatePeriod', - namespaces=self.SYN_NS) - assert a[0].text == period_type - - def test_update_frequency(self): - for frequency in (1, 100, 2000, 100000): - self.fg.syndication.update_frequency(frequency) - root = etree.fromstring(self.fg.rss_str()) - a = root.xpath('/rss/channel/sy:UpdateFrequency', - namespaces=self.SYN_NS) - assert a[0].text == str(frequency) - - def test_update_base(self): - base = '2000-01-01T12:00+00:00' - self.fg.syndication.update_base(base) - root = etree.fromstring(self.fg.rss_str()) - a = root.xpath('/rss/channel/sy:UpdateBase', namespaces=self.SYN_NS) - assert a[0].text == base - - -class TestExtensionPodcast(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('podcast') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_category_new(self): - self.fg.podcast.itunes_category([{'cat': 'Technology', - 'sub': 'Podcasting'}]) - self.fg.podcast.itunes_explicit('no') - self.fg.podcast.itunes_complete('no') - self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') - self.fg.podcast.itunes_owner('John Doe', 'john@example.com') - ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} - root = etree.fromstring(self.fg.rss_str()) - cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) - scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', - namespaces=ns) - assert cat[0] == 'Technology' - assert scat[0] == 'Podcasting' - - def test_category(self): - self.fg.podcast.itunes_category('Technology', 'Podcasting') - self.fg.podcast.itunes_explicit('no') - self.fg.podcast.itunes_complete('no') - self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') - self.fg.podcast.itunes_owner('John Doe', 'john@example.com') - ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} - root = etree.fromstring(self.fg.rss_str()) - cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) - scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', - namespaces=ns) - assert cat[0] == 'Technology' - assert scat[0] == 'Podcasting' - - def test_podcastItems(self): - fg = self.fg - fg.podcast.itunes_author('Lars Kiesow') - fg.podcast.itunes_block('x') - fg.podcast.itunes_complete(False) - fg.podcast.itunes_explicit('no') - fg.podcast.itunes_image('x.png') - fg.podcast.itunes_subtitle('x') - fg.podcast.itunes_summary('x') - assert fg.podcast.itunes_author() == 'Lars Kiesow' - assert fg.podcast.itunes_block() == 'x' - assert fg.podcast.itunes_complete() == 'no' - assert fg.podcast.itunes_explicit() == 'no' - assert fg.podcast.itunes_image() == 'x.png' - assert fg.podcast.itunes_subtitle() == 'x' - assert fg.podcast.itunes_summary() == 'x' - - # Check that we have the item in the resulting XML - ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} - root = etree.fromstring(self.fg.rss_str()) - author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns) - assert author == ['Lars Kiesow'] - - def test_podcastEntryItems(self): - fe = self.fg.add_item() - fe.title('y') - fe.podcast.itunes_author('Lars Kiesow') - fe.podcast.itunes_block('x') - fe.podcast.itunes_duration('00:01:30') - fe.podcast.itunes_explicit('no') - fe.podcast.itunes_image('x.png') - fe.podcast.itunes_is_closed_captioned('yes') - fe.podcast.itunes_order(1) - fe.podcast.itunes_subtitle('x') - fe.podcast.itunes_summary('x') - assert fe.podcast.itunes_author() == 'Lars Kiesow' - assert fe.podcast.itunes_block() == 'x' - assert fe.podcast.itunes_duration() == '00:01:30' - assert fe.podcast.itunes_explicit() == 'no' - assert fe.podcast.itunes_image() == 'x.png' - assert fe.podcast.itunes_is_closed_captioned() - assert fe.podcast.itunes_order() == 1 - assert fe.podcast.itunes_subtitle() == 'x' - assert fe.podcast.itunes_summary() == 'x' - - # Check that we have the item in the resulting XML - ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} - root = etree.fromstring(self.fg.rss_str()) - author = root.xpath('/rss/channel/item/itunes:author/text()', - namespaces=ns) - assert author == ['Lars Kiesow'] - - -class TestExtensionGeo(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('geo') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_geoEntryItems(self): - fe = self.fg.add_item() - fe.title('y') - fe.geo.point('42.36 -71.05') - - assert fe.geo.point() == '42.36 -71.05' - - # Check that we have the item in the resulting XML - ns = {'georss': 'http://www.georss.org/georss'} - root = etree.fromstring(self.fg.rss_str()) - point = root.xpath('/rss/channel/item/georss:point/text()', - namespaces=ns) - assert point == ['42.36 -71.05'] - - -class TestExtensionDc(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('dc') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_entryLoadExtension(self): - fe = self.fg.add_item() - try: - fe.load_extension('dc') - except ImportError: - pass # Extension already loaded - - def test_elements(self): - for method in dir(self.fg.dc): - if method.startswith('dc_'): - m = getattr(self.fg.dc, method) - m(method) - assert m() == [method] - - self.fg.id('123') - assert self.fg.atom_str() - assert self.fg.rss_str() - - -class TestExtensionTorrent(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('torrent') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_podcastEntryItems(self): - fe = self.fg.add_item() - fe.title('y') - fe.torrent.filename('file.xy') - fe.torrent.infohash('123') - fe.torrent.contentlength('23') - fe.torrent.seeds('1') - fe.torrent.peers('2') - fe.torrent.verified('1') - assert fe.torrent.filename() == 'file.xy' - assert fe.torrent.infohash() == '123' - assert fe.torrent.contentlength() == '23' - assert fe.torrent.seeds() == '1' - assert fe.torrent.peers() == '2' - assert fe.torrent.verified() == '1' - - # Check that we have the item in the resulting XML - ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} - root = etree.fromstring(self.fg.rss_str()) - filename = root.xpath('/rss/channel/item/torrent:filename/text()', - namespaces=ns) - assert filename == ['file.xy'] - - -class TestExtensionMedia(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('media') - self.fg.id('id') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_media_content(self): - fe = self.fg.add_item() - fe.id('id') - fe.title('title') - fe.content('content') - fe.media.content(url='file1.xy') - fe.media.content(url='file2.xy') - fe.media.content(url='file1.xy', group=2) - fe.media.content(url='file2.xy', group=2) - fe.media.content(url='file.xy', group=None) - - ns = {'media': 'http://search.yahoo.com/mrss/', - 'a': 'http://www.w3.org/2005/Atom'} - # Check that we have the item in the resulting RSS - root = etree.fromstring(self.fg.rss_str()) - url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', - namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] - - # There is one without a group - url = root.xpath('/rss/channel/item/media:content[1]/@url', - namespaces=ns) - assert url == ['file.xy'] - - # Check that we have the item in the resulting Atom feed - root = etree.fromstring(self.fg.atom_str()) - url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', - namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] - - fe.media.content(content=[], replace=True) - assert fe.media.content() == [] - - def test_media_thumbnail(self): - fe = self.fg.add_item() - fe.id('id') - fe.title('title') - fe.content('content') - fe.media.thumbnail(url='file1.xy') - fe.media.thumbnail(url='file2.xy') - fe.media.thumbnail(url='file1.xy', group=2) - fe.media.thumbnail(url='file2.xy', group=2) - fe.media.thumbnail(url='file.xy', group=None) - - ns = {'media': 'http://search.yahoo.com/mrss/', - 'a': 'http://www.w3.org/2005/Atom'} - # Check that we have the item in the resulting RSS - root = etree.fromstring(self.fg.rss_str()) - url = root.xpath( - '/rss/channel/item/media:group/media:thumbnail[1]/@url', - namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] - - # There is one without a group - url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url', - namespaces=ns) - assert url == ['file.xy'] - - # Check that we have the item in the resulting Atom feed - root = etree.fromstring(self.fg.atom_str()) - url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url', - namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] - - fe.media.thumbnail(thumbnail=[], replace=True) - assert fe.media.thumbnail() == [] diff --git a/tests/test_extensions/__init__.py b/tests/test_extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_extensions/test_dc.py b/tests/test_extensions/test_dc.py new file mode 100644 index 0000000..1623804 --- /dev/null +++ b/tests/test_extensions/test_dc.py @@ -0,0 +1,31 @@ +import unittest + +from feedgen.feed import FeedGenerator + + +class TestExtensionDc(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('dc') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_entryLoadExtension(self): + fe = self.fg.add_item() + try: + fe.load_extension('dc') + except ImportError: + pass # Extension already loaded + + def test_elements(self): + for method in dir(self.fg.dc): + if method.startswith('dc_'): + m = getattr(self.fg.dc, method) + m(method) + assert m() == [method] + + self.fg.id('123') + assert self.fg.atom_str() + assert self.fg.rss_str() diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py new file mode 100644 index 0000000..6665a74 --- /dev/null +++ b/tests/test_extensions/test_geo.py @@ -0,0 +1,29 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionGeo(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('geo') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_geoEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.point('42.36 -71.05') + + assert fe.geo.point() == '42.36 -71.05' + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + point = root.xpath('/rss/channel/item/georss:point/text()', + namespaces=ns) + assert point == ['42.36 -71.05'] diff --git a/tests/test_extensions/test_media.py b/tests/test_extensions/test_media.py new file mode 100644 index 0000000..7fd9e40 --- /dev/null +++ b/tests/test_extensions/test_media.py @@ -0,0 +1,83 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionMedia(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('media') + self.fg.id('id') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_media_content(self): + fe = self.fg.add_item() + fe.id('id') + fe.title('title') + fe.content('content') + fe.media.content(url='file1.xy') + fe.media.content(url='file2.xy') + fe.media.content(url='file1.xy', group=2) + fe.media.content(url='file2.xy', group=2) + fe.media.content(url='file.xy', group=None) + + ns = {'media': 'http://search.yahoo.com/mrss/', + 'a': 'http://www.w3.org/2005/Atom'} + # Check that we have the item in the resulting RSS + root = etree.fromstring(self.fg.rss_str()) + url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + # There is one without a group + url = root.xpath('/rss/channel/item/media:content[1]/@url', + namespaces=ns) + assert url == ['file.xy'] + + # Check that we have the item in the resulting Atom feed + root = etree.fromstring(self.fg.atom_str()) + url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + fe.media.content(content=[], replace=True) + assert fe.media.content() == [] + + def test_media_thumbnail(self): + fe = self.fg.add_item() + fe.id('id') + fe.title('title') + fe.content('content') + fe.media.thumbnail(url='file1.xy') + fe.media.thumbnail(url='file2.xy') + fe.media.thumbnail(url='file1.xy', group=2) + fe.media.thumbnail(url='file2.xy', group=2) + fe.media.thumbnail(url='file.xy', group=None) + + ns = {'media': 'http://search.yahoo.com/mrss/', + 'a': 'http://www.w3.org/2005/Atom'} + # Check that we have the item in the resulting RSS + root = etree.fromstring(self.fg.rss_str()) + url = root.xpath( + '/rss/channel/item/media:group/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + # There is one without a group + url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file.xy'] + + # Check that we have the item in the resulting Atom feed + root = etree.fromstring(self.fg.atom_str()) + url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + fe.media.thumbnail(thumbnail=[], replace=True) + assert fe.media.thumbnail() == [] diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py new file mode 100644 index 0000000..a41c96c --- /dev/null +++ b/tests/test_extensions/test_podcast.py @@ -0,0 +1,96 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionPodcast(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('podcast') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_category_new(self): + self.fg.podcast.itunes_category([{'cat': 'Technology', + 'sub': 'Podcasting'}]) + self.fg.podcast.itunes_explicit('no') + self.fg.podcast.itunes_complete('no') + self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') + self.fg.podcast.itunes_owner('John Doe', 'john@example.com') + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) + scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', + namespaces=ns) + assert cat[0] == 'Technology' + assert scat[0] == 'Podcasting' + + def test_category(self): + self.fg.podcast.itunes_category('Technology', 'Podcasting') + self.fg.podcast.itunes_explicit('no') + self.fg.podcast.itunes_complete('no') + self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') + self.fg.podcast.itunes_owner('John Doe', 'john@example.com') + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) + scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', + namespaces=ns) + assert cat[0] == 'Technology' + assert scat[0] == 'Podcasting' + + def test_podcastItems(self): + fg = self.fg + fg.podcast.itunes_author('Lars Kiesow') + fg.podcast.itunes_block('x') + fg.podcast.itunes_complete(False) + fg.podcast.itunes_explicit('no') + fg.podcast.itunes_image('x.png') + fg.podcast.itunes_subtitle('x') + fg.podcast.itunes_summary('x') + assert fg.podcast.itunes_author() == 'Lars Kiesow' + assert fg.podcast.itunes_block() == 'x' + assert fg.podcast.itunes_complete() == 'no' + assert fg.podcast.itunes_explicit() == 'no' + assert fg.podcast.itunes_image() == 'x.png' + assert fg.podcast.itunes_subtitle() == 'x' + assert fg.podcast.itunes_summary() == 'x' + + # Check that we have the item in the resulting XML + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns) + assert author == ['Lars Kiesow'] + + def test_podcastEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.podcast.itunes_author('Lars Kiesow') + fe.podcast.itunes_block('x') + fe.podcast.itunes_duration('00:01:30') + fe.podcast.itunes_explicit('no') + fe.podcast.itunes_image('x.png') + fe.podcast.itunes_is_closed_captioned('yes') + fe.podcast.itunes_order(1) + fe.podcast.itunes_subtitle('x') + fe.podcast.itunes_summary('x') + assert fe.podcast.itunes_author() == 'Lars Kiesow' + assert fe.podcast.itunes_block() == 'x' + assert fe.podcast.itunes_duration() == '00:01:30' + assert fe.podcast.itunes_explicit() == 'no' + assert fe.podcast.itunes_image() == 'x.png' + assert fe.podcast.itunes_is_closed_captioned() + assert fe.podcast.itunes_order() == 1 + assert fe.podcast.itunes_subtitle() == 'x' + assert fe.podcast.itunes_summary() == 'x' + + # Check that we have the item in the resulting XML + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + author = root.xpath('/rss/channel/item/itunes:author/text()', + namespaces=ns) + assert author == ['Lars Kiesow'] diff --git a/tests/test_extensions/test_syndication.py b/tests/test_extensions/test_syndication.py new file mode 100644 index 0000000..7a187d7 --- /dev/null +++ b/tests/test_extensions/test_syndication.py @@ -0,0 +1,40 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionSyndication(unittest.TestCase): + + SYN_NS = {'sy': 'http://purl.org/rss/1.0/modules/syndication/'} + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('syndication') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_update_period(self): + for period_type in ('hourly', 'daily', 'weekly', 'monthly', 'yearly'): + self.fg.syndication.update_period(period_type) + root = etree.fromstring(self.fg.rss_str()) + a = root.xpath('/rss/channel/sy:UpdatePeriod', + namespaces=self.SYN_NS) + assert a[0].text == period_type + + def test_update_frequency(self): + for frequency in (1, 100, 2000, 100000): + self.fg.syndication.update_frequency(frequency) + root = etree.fromstring(self.fg.rss_str()) + a = root.xpath('/rss/channel/sy:UpdateFrequency', + namespaces=self.SYN_NS) + assert a[0].text == str(frequency) + + def test_update_base(self): + base = '2000-01-01T12:00+00:00' + self.fg.syndication.update_base(base) + root = etree.fromstring(self.fg.rss_str()) + a = root.xpath('/rss/channel/sy:UpdateBase', namespaces=self.SYN_NS) + assert a[0].text == base diff --git a/tests/test_extensions/test_torrent.py b/tests/test_extensions/test_torrent.py new file mode 100644 index 0000000..e996fde --- /dev/null +++ b/tests/test_extensions/test_torrent.py @@ -0,0 +1,38 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionTorrent(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('torrent') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_podcastEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.torrent.filename('file.xy') + fe.torrent.infohash('123') + fe.torrent.contentlength('23') + fe.torrent.seeds('1') + fe.torrent.peers('2') + fe.torrent.verified('1') + assert fe.torrent.filename() == 'file.xy' + assert fe.torrent.infohash() == '123' + assert fe.torrent.contentlength() == '23' + assert fe.torrent.seeds() == '1' + assert fe.torrent.peers() == '2' + assert fe.torrent.verified() == '1' + + # Check that we have the item in the resulting XML + ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} + root = etree.fromstring(self.fg.rss_str()) + filename = root.xpath('/rss/channel/item/torrent:filename/text()', + namespaces=ns) + assert filename == ['file.xy'] From 8cd50bf768def7e50754496e7ed48a533ec38252 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 14:58:21 +1000 Subject: [PATCH 09/47] Add unit tests for simple GeoRSS Also fix a couple of bugs that came up during testing - mostly making sure that elevation, floor and radius are actually set as strings in the XML --- feedgen/ext/geo_entry.py | 20 +- tests/test_extensions/test_geo.py | 347 +++++++++++++++++++++++++++++- 2 files changed, 353 insertions(+), 14 deletions(-) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index e57fc81..03e8f41 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -114,15 +114,15 @@ def extend_file(self, entry): if self.__elev: elevation = etree.SubElement(entry, '{%s}elev' % GEO_NS) - elevation.text = self.__elev + elevation.text = str(self.__elev) if self.__floor: floor = etree.SubElement(entry, '{%s}floor' % GEO_NS) - floor.text = self.__floor + floor.text = str(self.__floor) if self.__radius: radius = etree.SubElement(entry, '{%s}radius' % GEO_NS) - radius.text = self.__radius + radius.text = str(self.__radius) return entry @@ -178,7 +178,7 @@ def box(self, box=None): return self.__box - def featuretypetag(self, featuretypetag): + def featuretypetag(self, featuretypetag=None): ''' Get or set the georss:featuretypetag of the entry @@ -190,7 +190,7 @@ def featuretypetag(self, featuretypetag): return self.__featuretypetag - def relationshiptag(self, relationshiptag): + def relationshiptag(self, relationshiptag=None): ''' Get or set the georss:relationshiptag of the entry @@ -202,11 +202,11 @@ def relationshiptag(self, relationshiptag): return self.__relationshiptag - def featurename(self, featurename): + def featurename(self, featurename=None): ''' Get or set the georss:featurename of the entry - :param featuretypetag: The GeoRSS featurename (e.g. "city") + :param featuretypetag: The GeoRSS featurename (e.g. "Footscray") :return: the current georss:featurename ''' if featurename is not None: @@ -214,7 +214,7 @@ def featurename(self, featurename): return self.__featurename - def elev(self, elev): + def elev(self, elev=None): ''' Get or set the georss:elev of the entry @@ -230,7 +230,7 @@ def elev(self, elev): return self.__elev - def floor(self, floor): + def floor(self, floor=None): ''' Get or set the georss:floor of the entry @@ -246,7 +246,7 @@ def floor(self, floor): return self.__floor - def radius(self, radius): + def radius(self, radius=None): ''' Get or set the georss:radius of the entry diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 6665a74..3855bf4 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -1,12 +1,104 @@ import unittest +import warnings from lxml import etree from feedgen.feed import FeedGenerator +from feedgen.ext.geo_entry import GeoRSSPolygonInteriorWarning, GeoRSSGeometryError + + +class Geom(object): + """ + Dummy geom to make testing easier + + When we use the geo-interface we need a class with a `__geo_interface__` + property. Makes it easier for the other tests as well. + + Ultimately this could be used to generate dummy geometries for testing + a wider variety of values (e.g. with the faker library, or the hypothesis + library) + """ + + def __init__(self, geom_type, coords): + self.geom_type = geom_type + self.coords = coords + + def __str__(self): + if self.geom_type == 'Point': + + coords = '%f %f'.format( + self.coords[1], # latitude is y + self.coords[0] + ) + return coords + + elif self.geom_type == 'LineString': + + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + self.coords + ) + return coords + + elif self.geom_type == 'Polygon': + + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + self.coords[0] + ) + return coords + + elif self.geom_type == 'Box': + # box not really supported by GeoJSON, but it's a handy cheat here + # for testing + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + self.coords + ) + return coords[:2] + + else: + return 'Not a supported geometry' + + @property + def __geo_interface__(self): + return { + 'type': self.geom_type, + 'coordinates': self.coords + } class TestExtensionGeo(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.point = Geom('Point', [-71.05, 42.36]) + cls.line = Geom('LineString', [[-71.05, 42.36], [-71.15, 42.46]]) + cls.polygon = Geom('Polygon', [[[-71.05, 42.36], [-71.15, 42.46], [-71.15, 42.36]]]) + cls.box = Geom('Box', [[-71.05, 42.36], [-71.15, 42.46]]) + cls.polygon_with_interior = Geom( + 'Polygon', + [ + [ # exterior + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0] + ], + [ # interior + [0.25, 0.25], + [0.25, 0.75], + [0.75, 0.75], + [0.75, 0.25], + [0.25, 0.25] + ] + ] + ) + def setUp(self): self.fg = FeedGenerator() self.fg.load_extension('geo') @@ -14,16 +106,263 @@ def setUp(self): self.fg.link(href='http://example.com', rel='self') self.fg.description('description') - def test_geoEntryItems(self): + def test_point(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.point(str(self.point)) + + self.assertEqual(fe.geo.point(), str(self.point)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + point = root.xpath('/rss/channel/item/georss:point/text()', + namespaces=ns) + self.assertEqual(point, [str(self.point)]) + + def test_line(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.line(str(self.line)) + + self.assertEqual(fe.geo.line(), str(self.line)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + line = root.xpath('/rss/channel/item/georss:line/text()', + namespaces=ns) + self.assertEqual(line, [str(self.line)]) + + def test_polygon(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.polygon(str(self.polygon)) + + self.assertEqual(fe.geo.polygon(), str(self.polygon)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath('/rss/channel/item/georss:polygon/text()', + namespaces=ns) + self.assertEqual(poly, [str(self.polygon)]) + + def test_box(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.box(str(self.box)) + + self.assertEqual(fe.geo.box(), str(self.box)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + box = root.xpath('/rss/channel/item/georss:box/text()', + namespaces=ns) + self.assertEqual(box, [str(self.box)]) + + def test_featuretypetag(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.featuretypetag('city') + + self.assertEqual(fe.geo.featuretypetag(), 'city') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + featuretypetag = root.xpath( + '/rss/channel/item/georss:featuretypetag/text()', + namespaces=ns + ) + self.assertEqual(featuretypetag, ['city']) + + def test_relationshiptag(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.relationshiptag('is-centred-at') + + self.assertEqual(fe.geo.relationshiptag(), 'is-centred-at') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + relationshiptag = root.xpath( + '/rss/channel/item/georss:relationshiptag/text()', + namespaces=ns + ) + self.assertEqual(relationshiptag, ['is-centred-at']) + + def test_featurename(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.featurename('Footscray') + + self.assertEqual(fe.geo.featurename(), 'Footscray') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + featurename = root.xpath( + '/rss/channel/item/georss:featurename/text()', + namespaces=ns + ) + self.assertEqual(featurename, ['Footscray']) + + def test_elev(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.elev(100.3) + + self.assertEqual(fe.geo.elev(), 100.3) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + elev = root.xpath( + '/rss/channel/item/georss:elev/text()', + namespaces=ns + ) + self.assertEqual(elev, ['100.3']) + + def test_elev_fails_nonnumeric(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.elev('100.3') + + def test_floor(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.floor(4) + + self.assertEqual(fe.geo.floor(), 4) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + floor = root.xpath( + '/rss/channel/item/georss:floor/text()', + namespaces=ns + ) + self.assertEqual(floor, ['4']) + + def test_floor_fails_nonint(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.floor(100.3) + + with self.assertRaises(ValueError): + fe.geo.floor('4') + + def test_radius(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.radius(100.3) + + self.assertEqual(fe.geo.radius(), 100.3) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + radius = root.xpath( + '/rss/channel/item/georss:radius/text()', + namespaces=ns + ) + self.assertEqual(radius, ['100.3']) + + def test_radius_fails_nonnumeric(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.radius('100.3') + + def test_geom_from_geointerface_point(self): fe = self.fg.add_item() fe.title('y') - fe.geo.point('42.36 -71.05') + fe.geo.geom_from_geo_interface(self.point) - assert fe.geo.point() == '42.36 -71.05' + self.assertEqual(fe.geo.point(), str(self.point)) # Check that we have the item in the resulting XML ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) point = root.xpath('/rss/channel/item/georss:point/text()', namespaces=ns) - assert point == ['42.36 -71.05'] + self.assertEqual(point, [str(self.point)]) + + def test_geom_from_geointerface_line(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.geom_from_geo_interface(self.line) + + self.assertEqual(fe.geo.line(), str(self.line)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + line = root.xpath('/rss/channel/item/georss:line/text()', + namespaces=ns) + self.assertEqual(line, [str(self.line)]) + + def test_geom_from_geointerface_poly(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.geom_from_geo_interface(self.polygon) + + self.assertEqual(fe.geo.polygon(), str(self.polygon)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath('/rss/channel/item/georss:polygon/text()', + namespaces=ns) + self.assertEqual(poly, [str(self.polygon)]) + + def test_geom_from_geointerface_fail_other_geom(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(GeoRSSGeometryError): + fe.geo.geom_from_geo_interface(self.box) + + def test_geom_from_geointerface_fail_requires_geo_interface(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(AttributeError): + fe.geo.geom_from_geo_interface(str(self.box)) + + + def test_geom_from_geointerface_warn_poly_interior(self): + """ + Test complex polygons warn as expected. Taken from + + https://stackoverflow.com/a/3892301/379566 and + https://docs.python.org/2.7/library/warnings.html#testing-warnings + """ + fe = self.fg.add_item() + fe.title('y') + + with warnings.catch_warnings(record=True) as w: + # Cause all warnings to always be triggered. + warnings.simplefilter("always") + # Trigger a warning. + fe.geo.geom_from_geo_interface(self.polygon_with_interior) + # Verify some things + assert len(w) == 1 + assert issubclass(w[-1].category, GeoRSSPolygonInteriorWarning) + + self.assertEqual(fe.geo.polygon(), str(self.polygon_with_interior)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath('/rss/channel/item/georss:polygon/text()', + namespaces=ns) + self.assertEqual(poly, [str(self.polygon_with_interior)]) From b02278e536f09d19ceb93de0e196a00d06bb4af9 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 15:16:20 +1000 Subject: [PATCH 10/47] Fix a really dumb formatting issue for the geom_interface Use the old formatting tag instead of the new when creating geom text from the geo_interface. Tests updated as well --- feedgen/ext/geo_entry.py | 6 +++--- tests/test_extensions/test_geo.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 03e8f41..2fcc409 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -294,7 +294,7 @@ def geom_from_geo_interface(self, geom): if geojson['type'] == 'Point': - coords = '%f %f'.format( + coords = '{:f} {:f}'.format( geojson['coordinates'][1], # latitude is y geojson['coordinates'][0] ) @@ -303,7 +303,7 @@ def geom_from_geo_interface(self, geom): elif geojson['type'] == 'LineString': coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in geojson['coordinates'] ) @@ -315,7 +315,7 @@ def geom_from_geo_interface(self, geom): warnings.warn(GeoRSSPolygonInteriorWarning(geom)) coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in geojson['coordinates'][0] ) diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 3855bf4..35dfa84 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -26,7 +26,7 @@ def __init__(self, geom_type, coords): def __str__(self): if self.geom_type == 'Point': - coords = '%f %f'.format( + coords = '{:f} {:f}'.format( self.coords[1], # latitude is y self.coords[0] ) @@ -35,7 +35,7 @@ def __str__(self): elif self.geom_type == 'LineString': coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in self.coords ) @@ -44,7 +44,7 @@ def __str__(self): elif self.geom_type == 'Polygon': coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in self.coords[0] ) @@ -54,7 +54,7 @@ def __str__(self): # box not really supported by GeoJSON, but it's a handy cheat here # for testing coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in self.coords ) From 9586e7bcf18de04d1d6b436340b11b7ea2f59609 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 15:39:33 +1000 Subject: [PATCH 11/47] Add a unit test to confirm all coordinates Make sure that all the required coordinates are in the GeoRSS string, to avoid the mistake made earlier. --- tests/test_extensions/test_geo.py | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 35dfa84..01990a6 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -1,3 +1,4 @@ +from itertools import chain import unittest import warnings @@ -296,6 +297,21 @@ def test_geom_from_geointerface_point(self): namespaces=ns) self.assertEqual(point, [str(self.point)]) + coords = [float(c) for c in point[0].split()] + + try: + self.assertCountEqual( + coords, + self.point.coords + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.point.coords)) + ) + + + def test_geom_from_geointerface_line(self): fe = self.fg.add_item() fe.title('y') @@ -310,6 +326,20 @@ def test_geom_from_geointerface_line(self): namespaces=ns) self.assertEqual(line, [str(self.line)]) + coords = [float(c) for c in line[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.line.coords)) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.line.coords)) + ) + + def test_geom_from_geointerface_poly(self): fe = self.fg.add_item() fe.title('y') @@ -324,6 +354,19 @@ def test_geom_from_geointerface_poly(self): namespaces=ns) self.assertEqual(poly, [str(self.polygon)]) + coords = [float(c) for c in poly[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.polygon.coords[0])) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.polygon.coords[0])) + ) + def test_geom_from_geointerface_fail_other_geom(self): fe = self.fg.add_item() fe.title('y') @@ -366,3 +409,16 @@ def test_geom_from_geointerface_warn_poly_interior(self): poly = root.xpath('/rss/channel/item/georss:polygon/text()', namespaces=ns) self.assertEqual(poly, [str(self.polygon_with_interior)]) + + coords = [float(c) for c in poly[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.polygon_with_interior.coords[0])) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.polygon_with_interior.coords[0])) + ) From 66f8bdb45ec6973efca3c712678a88a4ef020a2e Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 29 Jul 2019 10:24:02 +1000 Subject: [PATCH 12/47] Fix errors from make test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Python 2.7 error in the test (didn’t work for points) Also fixed all the formatting errors raised by flake8 --- feedgen/ext/geo_entry.py | 43 ++++++++++++++++++++----------- tests/test_extensions/test_geo.py | 35 ++++++++++++++----------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 2fcc409..2ad6611 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -30,8 +30,9 @@ def __init__(self, geom, *args, **kwargs): def __str__(self): return '{:d} interiors of polygon ignored'.format( - len(self.geom.__geo_interface__['coordinates']) - 1 # ignore exterior in count - ) + len(self.geom.__geo_interface__['coordinates']) - 1 + ) # ignore exterior in count + class GeoRSSGeometryError(ValueError): """ @@ -39,7 +40,6 @@ class GeoRSSGeometryError(ValueError): Only some geometries are supported in Simple GeoRSS, so if not raise an error. Offending geometry is stored on the ``geom`` attribute. - """ def __init__(self, geom, *args, **kwargs): @@ -47,7 +47,8 @@ def __init__(self, geom, *args, **kwargs): super(GeoRSSGeometryError, self).__init__(*args, **kwargs) def __str__(self): - return "Geometry of type '{}' not in Point, Linestring or Polygon".format( + msg = "Geometry of type '{}' not in Point, Linestring or Polygon" + return msg.format( self.geom.__geo_interface__['type'] ) @@ -101,11 +102,17 @@ def extend_file(self, entry): box.text = self.__box if self.__featuretypetag: - featuretypetag = etree.SubElement(entry, '{%s}featuretypetag' % GEO_NS) + featuretypetag = etree.SubElement( + entry, + '{%s}featuretypetag' % GEO_NS + ) featuretypetag.text = self.__featuretypetag if self.__relationshiptag: - relationshiptag = etree.SubElement(entry, '{%s}relationshiptag' % GEO_NS) + relationshiptag = etree.SubElement( + entry, + '{%s}relationshiptag' % GEO_NS + ) relationshiptag.text = self.__relationshiptag if self.__featurename: @@ -147,7 +154,8 @@ def point(self, point=None): def line(self, line=None): '''Get or set the georss:line of the entry - :param point: The GeoRSS formatted line (i.e. "45.256 -110.45 46.46 -109.48 43.84 -109.86") + :param point: The GeoRSS formatted line (i.e. "45.256 -110.45 46.46 + -109.48 43.84 -109.86") :return: The current georss:line of the entry ''' if line is not None: @@ -158,7 +166,8 @@ def line(self, line=None): def polygon(self, polygon=None): '''Get or set the georss:polygon of the entry - :param polygon: The GeoRSS formatted polygon (i.e. "45.256 -110.45 46.46 -109.48 43.84 -109.86 45.256 -110.45") + :param polygon: The GeoRSS formatted polygon (i.e. "45.256 -110.45 + 46.46 -109.48 43.84 -109.86 45.256 -110.45") :return: The current georss:polygon of the entry ''' if polygon is not None: @@ -170,7 +179,8 @@ def box(self, box=None): ''' Get or set the georss:box of the entry - :param box: The GeoRSS formatted box (i.e. "42.943 -71.032 43.039 -69.856") + :param box: The GeoRSS formatted box (i.e. "42.943 -71.032 43.039 + -69.856") :return: The current georss:box of the entry ''' if box is not None: @@ -194,7 +204,8 @@ def relationshiptag(self, relationshiptag=None): ''' Get or set the georss:relationshiptag of the entry - :param relationshiptag: The GeoRSS relationshiptag (e.g. "is-centred-at") + :param relationshiptag: The GeoRSS relationshiptag (e.g. + "is-centred-at") :return: the current georss:relationshiptag ''' if relationshiptag is not None: @@ -256,7 +267,9 @@ def radius(self, radius=None): ''' if radius is not None: if not isinstance(radius, numbers.Number): - raise ValueError("radius tag must be numeric: {}".format(radius)) + raise ValueError( + "radius tag must be numeric: {}".format(radius) + ) self.__radius = radius @@ -268,13 +281,13 @@ def geom_from_geo_interface(self, geom): ``__geo_interface__`` property (see the `geo_interface specification by Sean Gillies`_geointerface ) - Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be easily - converted to GeoRSS: + Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be + easily converted to GeoRSS: - Point - LineString - - Polygon (if there are holes / donuts in the polygons a warning will be - generaated + - Polygon (if there are holes / donuts in the polygons a warning will + be generaated Other GeoJson types will raise a ``ValueError``. diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 01990a6..6dd401b 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -5,7 +5,7 @@ from lxml import etree from feedgen.feed import FeedGenerator -from feedgen.ext.geo_entry import GeoRSSPolygonInteriorWarning, GeoRSSGeometryError +from feedgen.ext.geo_entry import GeoRSSPolygonInteriorWarning, GeoRSSGeometryError # noqa: E501 class Geom(object): @@ -78,19 +78,22 @@ class TestExtensionGeo(unittest.TestCase): def setUpClass(cls): cls.point = Geom('Point', [-71.05, 42.36]) cls.line = Geom('LineString', [[-71.05, 42.36], [-71.15, 42.46]]) - cls.polygon = Geom('Polygon', [[[-71.05, 42.36], [-71.15, 42.46], [-71.15, 42.36]]]) + cls.polygon = Geom( + 'Polygon', + [[[-71.05, 42.36], [-71.15, 42.46], [-71.15, 42.36]]] + ) cls.box = Geom('Box', [[-71.05, 42.36], [-71.15, 42.46]]) cls.polygon_with_interior = Geom( 'Polygon', [ - [ # exterior + [ # exterior [0, 0], [0, 1], [1, 1], [1, 0], [0, 0] ], - [ # interior + [ # interior [0.25, 0.25], [0.25, 0.75], [0.75, 0.75], @@ -131,8 +134,10 @@ def test_line(self): # Check that we have the item in the resulting XML ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) - line = root.xpath('/rss/channel/item/georss:line/text()', - namespaces=ns) + line = root.xpath( + '/rss/channel/item/georss:line/text()', + namespaces=ns + ) self.assertEqual(line, [str(self.line)]) def test_polygon(self): @@ -145,8 +150,10 @@ def test_polygon(self): # Check that we have the item in the resulting XML ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) - poly = root.xpath('/rss/channel/item/georss:polygon/text()', - namespaces=ns) + poly = root.xpath( + '/rss/channel/item/georss:polygon/text()', + namespaces=ns + ) self.assertEqual(poly, [str(self.polygon)]) def test_box(self): @@ -159,8 +166,10 @@ def test_box(self): # Check that we have the item in the resulting XML ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) - box = root.xpath('/rss/channel/item/georss:box/text()', - namespaces=ns) + box = root.xpath( + '/rss/channel/item/georss:box/text()', + namespaces=ns + ) self.assertEqual(box, [str(self.box)]) def test_featuretypetag(self): @@ -307,11 +316,9 @@ def test_geom_from_geointerface_point(self): except AttributeError: # was assertItemsEqual in Python 2.7 self.assertItemsEqual( coords, - list(chain.from_iterable(self.point.coords)) + self.point.coords ) - - def test_geom_from_geointerface_line(self): fe = self.fg.add_item() fe.title('y') @@ -339,7 +346,6 @@ def test_geom_from_geointerface_line(self): list(chain.from_iterable(self.line.coords)) ) - def test_geom_from_geointerface_poly(self): fe = self.fg.add_item() fe.title('y') @@ -381,7 +387,6 @@ def test_geom_from_geointerface_fail_requires_geo_interface(self): with self.assertRaises(AttributeError): fe.geo.geom_from_geo_interface(str(self.box)) - def test_geom_from_geointerface_warn_poly_interior(self): """ Test complex polygons warn as expected. Taken from From ca25295ac302ba501bc86c9670a895ff180333b3 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 11 Aug 2019 22:22:41 +0200 Subject: [PATCH 13/47] Update Python Versions This patch drops tests of the rather dated Python 3.4 and 3.5 while simultaneously adding tests for Python 3.7 and updating the build and test environment. --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4c50301..717381a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,12 @@ language: python -sudo: false +dist: bionic # https://devguide.python.org/#branchstatus python: - "2.7" - - "3.4" - - "3.5" - "3.6" + - "3.7" install: - pip install flake8 python-coveralls coverage From 8e5e8845af213e271812bf58498a82579bc5dbab Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 11 Aug 2019 22:45:27 +0200 Subject: [PATCH 14/47] Update Documentation - Update Fedora installation instructions - Use HTTPS for links --- readme.rst | 16 ++++------------ setup.py | 2 +- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/readme.rst b/readme.rst index 6558505..550739d 100644 --- a/readme.rst +++ b/readme.rst @@ -22,7 +22,7 @@ at license.bsd and license.lgpl. More details about the project: - Repository: https://github.com/lkiesow/python-feedgen -- Documentation: http://lkiesow.github.io/python-feedgen/ +- Documentation: https://lkiesow.github.io/python-feedgen/ - Python Package Index: https://pypi.python.org/pypi/feedgen/ @@ -32,18 +32,10 @@ Installation **Prebuild packages** -If you are running Fedora Linux, RedHat Enterprise Linux, CentOS or Scientific -Linux you can use the RPM Copr repository: +If your distribution includes this project as package, like Fedora Linux does, +you can simply use your package manager to install the package. For example:: -http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/ - -Simply enable the repository and run:: - - $ yum install python-feedgen - -or for the Python 3 package:: - - $ yum install python3-feedgen + $ dnf install python3-feedgen **Using pip** diff --git a/setup.py b/setup.py index 1549284..a5e2021 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ description='Feed Generator (ATOM, RSS, Podcasts)', author='Lars Kiesow', author_email='lkiesow@uos.de', - url='http://lkiesow.github.io/python-feedgen', + url='https://lkiesow.github.io/python-feedgen', keywords=['feed', 'ATOM', 'RSS', 'podcast'], license='FreeBSD and LGPLv3+', install_requires=['lxml', 'python-dateutil'], From b6a60c78835ad03e8bb849b5c4fa21bf430bc294 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 11 Aug 2019 22:55:27 +0200 Subject: [PATCH 15/47] Add requirements.txt --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c01ee4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +lxml==4.2.5 +python_dateutil==2.8.0 From ff236964ef9e60cc405a1a9f777126191823e969 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 11 Aug 2019 23:04:17 +0200 Subject: [PATCH 16/47] License Formatting --- license.bsd | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/license.bsd b/license.bsd index a8e1879..beac4ea 100644 --- a/license.bsd +++ b/license.bsd @@ -1,31 +1,25 @@ +BSD 2-Clause License -Copyright 2011 Lars Kiesow. All rights reserved. -http://www.larskiesow.de +Copyright 2011, Lars Kiesow +All rights reserved. Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: +modification, are permitted provided that the following conditions are met: -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY -EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS -BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation -are those of the authors and should not be interpreted as representing -official policies, either expressed or implied, of everyone working on -this project. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 9e3146f2ab2e2701ec423e74d7e93865c39b5550 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 8 Sep 2019 12:20:13 +0200 Subject: [PATCH 17/47] Release 0.8.0 - Implement complete GeoRSS specification - Allow CDATA content in RSS description - Add source element to feed entries - Fixed a number of typos --- feedgen/version.py | 4 ++-- python-feedgen.spec | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index 1481720..436c851 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -3,14 +3,14 @@ feedgen.version ~~~~~~~~~~~~~~~ - :copyright: 2013-2017, Lars Kiesow + :copyright: 2013-2018, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' 'Version of python-feedgen represented as tuple' -version = (0, 7, 0) +version = (0, 8, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 0170fd0..eac1fcb 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %global pypi_name feedgen Name: python-%{pypi_name} -Version: 0.7.0 +Version: 0.8.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) From edd988f8a6d0ba693a5ea494dae7311fdc7f1019 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 18 Oct 2019 23:56:36 +0200 Subject: [PATCH 18/47] Automated License Check This patch adds a license checker to the automated tests. This ensures that only dependencies with licenses from a list of known good licenses are used. --- .licenses.ini | 9 +++++++++ .travis.yml | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .licenses.ini diff --git a/.licenses.ini b/.licenses.ini new file mode 100644 index 0000000..cac5032 --- /dev/null +++ b/.licenses.ini @@ -0,0 +1,9 @@ +# Authorized licenses in lower case + +# There is no project rule against adding new licenses as long as they are +# compatible with the project's license. + +[Licenses] +authorized_licenses: + BSD + MIT diff --git a/.travis.yml b/.travis.yml index 717381a..652fae3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,12 +9,14 @@ python: - "3.7" install: - - pip install flake8 python-coveralls coverage + - pip install flake8 python-coveralls coverage liccheck + - pip install -r requirements.txt - python setup.py bdist_wheel - pip install dist/feedgen* script: - make test + - liccheck -s .licenses.ini - python -m feedgen - python -m feedgen atom - python -m feedgen rss From e7714888540fe82ddcc41d9754259d4de59e1759 Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Sun, 24 Nov 2019 12:44:00 -0500 Subject: [PATCH 19/47] Add support for html summaries for Atom feeds - Add a test - Update existing test - Make flake8 happy --- feedgen/entry.py | 88 ++++++++++++++++++++++++++------------------- tests/test_entry.py | 15 +++++++- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index ab3e84b..ea86581 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -19,6 +19,45 @@ from feedgen.util import ensure_format, formatRFC2822 +def _add_text_elm(entry, data, name): + """Add a text subelement to an entry""" + if not data: + return + + elm = etree.SubElement(entry, name) + type_ = data.get('type') + if data.get('src'): + if name != 'content': + raise ValueError("Only the 'content' element of an entry can " + "contain a 'src' attribute") + elm.attrib['src'] = data['src'] + elif data.get(name): + # Surround xhtml with a div tag, parse it and embed it + if type_ == 'xhtml': + elm.append(etree.fromstring( + '
' + + data.get(name) + '
')) + elif type_ == 'CDATA': + elm.text = etree.CDATA( + data.get(name)) + # Emed the text in escaped form + elif not type_ or type_.startswith('text') or type_ == 'html': + elm.text = data.get(name) + # Parse XML and embed it + elif type_.endswith('/xml') or type_.endswith('+xml'): + elm.append(etree.fromstring( + data[name])) + # Everything else should be included base64 encoded + else: + raise ValueError( + 'base64 encoded {} is not supported at the moment. ' + 'Pull requests adding support are welcome.'.format(name) + ) + # Add type description of the content + if type_: + elm.attrib['type'] = type_ + + class FeedEntry(object): '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item node. @@ -96,35 +135,7 @@ def atom_entry(self, extensions=True): uri = etree.SubElement(author, 'uri') uri.text = a.get('uri') - if self.__atom_content: - content = etree.SubElement(entry, 'content') - type = self.__atom_content.get('type') - if self.__atom_content.get('src'): - content.attrib['src'] = self.__atom_content['src'] - elif self.__atom_content.get('content'): - # Surround xhtml with a div tag, parse it and embed it - if type == 'xhtml': - content.append(etree.fromstring( - '
' + - self.__atom_content.get('content') + '
')) - elif type == 'CDATA': - content.text = etree.CDATA( - self.__atom_content.get('content')) - # Emed the text in escaped form - elif not type or type.startswith('text') or type == 'html': - content.text = self.__atom_content.get('content') - # Parse XML and embed it - elif type.endswith('/xml') or type.endswith('+xml'): - content.append(etree.fromstring( - self.__atom_content['content'])) - # Everything else should be included base64 encoded - else: - raise ValueError('base64 encoded content is not ' + - 'supported at the moment. Pull requests' + - ' adding support are welcome.') - # Add type description of the content - if type: - content.attrib['type'] = type + _add_text_elm(entry, self.__atom_content, 'content') for l in self.__atom_link or []: link = etree.SubElement(entry, 'link', href=l['href']) @@ -139,9 +150,7 @@ def atom_entry(self, extensions=True): if l.get('length'): link.attrib['length'] = l['length'] - if self.__atom_summary: - summary = etree.SubElement(entry, 'summary') - summary.text = self.__atom_summary + _add_text_elm(entry, self.__atom_summary, 'summary') for c in self.__atom_category or []: cat = etree.SubElement(entry, 'category', term=c['term']) @@ -453,7 +462,7 @@ def link(self, link=None, replace=False, **kwargs): # return the set with more information (atom) return self.__atom_link - def summary(self, summary=None): + def summary(self, summary=None, type=None): '''Get or set the summary element of an entry which conveys a short summary, abstract, or excerpt of the entry. Summary is an ATOM only element and should be provided if there either is no content provided @@ -467,11 +476,16 @@ def summary(self, summary=None): ''' if summary is not None: # Replace the RSS description with the summary if it was the - # summary before. Not if is the description. - if not self.__rss_description or \ - self.__rss_description == self.__atom_summary: + # summary before. Not if it is the description. + if not self.__rss_description or ( + self.__atom_summary and + self.__rss_description == self.__atom_summary.get("summary") + ): self.__rss_description = summary - self.__atom_summary = summary + + self.__atom_summary = {'summary': summary} + if type is not None: + self.__atom_summary['type'] = type return self.__atom_summary def description(self, description=None, isSummary=False): diff --git a/tests/test_entry.py b/tests/test_entry.py index 6c9835a..5eaeffa 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -84,7 +84,7 @@ def test_TestEntryItems(self): fe.updated('2017-02-05 13:26:58+01:00') assert fe.updated().year == 2017 fe.summary('asdf') - assert fe.summary() == 'asdf' + assert fe.summary() == {'summary': 'asdf'} fe.description('asdfx') assert fe.description() == 'asdfx' fe.pubDate('2017-02-05 13:26:58+01:00') @@ -164,3 +164,16 @@ def test_content_cdata_type(self): fe.content('content', type='CDATA') result = fg.atom_str() assert b'' in result + + def test_summary_html_type(self): + fg = FeedGenerator() + fg.title('some title') + fg.id('http://lernfunk.de/media/654322/1') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654322/1') + fe.title('some title') + fe.link(href='http://lernfunk.de/media/654322/1') + fe.summary('

summary

', type='html') + result = fg.atom_str() + expected = b'<p>summary</p>' + assert expected in result From d1e77c78ee43d4ade0a3aebe3183eb132137c82b Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Mon, 25 Nov 2019 23:19:41 -0500 Subject: [PATCH 20/47] Use more appropriate exception class for unimplemented functionality --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index ea86581..e90cbfd 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -49,7 +49,7 @@ def _add_text_elm(entry, data, name): data[name])) # Everything else should be included base64 encoded else: - raise ValueError( + raise NotImplementedError( 'base64 encoded {} is not supported at the moment. ' 'Pull requests adding support are welcome.'.format(name) ) From 26b64ca9fcac6b8e53132698b9fe0d4971993a4d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 02:11:39 +0100 Subject: [PATCH 21/47] Properly Parse text/xml This patch fixes the problem that content with the MIME type `text/xml` is accidentally treated as text rather than as XML. --- feedgen/entry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index e90cbfd..8617bb2 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -40,13 +40,13 @@ def _add_text_elm(entry, data, name): elif type_ == 'CDATA': elm.text = etree.CDATA( data.get(name)) - # Emed the text in escaped form - elif not type_ or type_.startswith('text') or type_ == 'html': - elm.text = data.get(name) # Parse XML and embed it - elif type_.endswith('/xml') or type_.endswith('+xml'): + elif type_ and (type_.endswith('/xml') or type_.endswith('+xml')): elm.append(etree.fromstring( data[name])) + # Embed the text in escaped form + elif not type_ or type_.startswith('text') or type_ == 'html': + elm.text = data.get(name) # Everything else should be included base64 encoded else: raise NotImplementedError( From 5a68e682d902a9d3b7f11dbf3f62ae8c623bb18f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 24 Jan 2020 17:51:37 +0100 Subject: [PATCH 22/47] Make Generator Optional (Atom) This patch makes the field generator optional in Atom, allowing to set an empty string to disable the element in the same way it is disabled in RSS already: ```python # disable generator element fg.generator('') ``` This fixes #89 --- feedgen/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 710aadc..b2a206f 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -165,7 +165,7 @@ def _create_atom(self, extensions=True): uri = etree.SubElement(contrib, 'uri') uri.text = c.get('uri') - if self.__atom_generator: + if self.__atom_generator and self.__atom_generator.get('value'): generator = etree.SubElement(feed, 'generator') generator.text = self.__atom_generator['value'] if self.__atom_generator.get('uri'): From 771b45021b35e58bddc3614164af9560ce1b03d4 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 21:39:55 +0100 Subject: [PATCH 23/47] Add Security Policy This patch adds a security policy stating how to handle and repost security issues found in this library. --- SECURITY.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7af7bb8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +Security Policy +=============== + +Supported Versions +------------------ + +Only the latest version of this library is supported. +We are doing our best to make updates as easy as possible +so that keeping up-to-date is usually pretty easy. + + +Reporting a Vulnerability +------------------------- + +If you find a security vulnerability, +please report it by sending a mail to security@lkiesow.de. +We will discuss the problem internally and, if necessary, release a patched version as soon as possible. From e942a0839ef6f5b2c4a1dbd6e84e428de179025f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 22:09:52 +0100 Subject: [PATCH 24/47] Documentation Update Just updating a few minor bits of the documentation. --- SECURITY.md => SECURITY.rst | 0 readme.rst | 118 ++++++++++++++++++++---------------- 2 files changed, 65 insertions(+), 53 deletions(-) rename SECURITY.md => SECURITY.rst (100%) diff --git a/SECURITY.md b/SECURITY.rst similarity index 100% rename from SECURITY.md rename to SECURITY.rst diff --git a/readme.rst b/readme.rst index 550739d..3aa2c8f 100644 --- a/readme.rst +++ b/readme.rst @@ -21,9 +21,9 @@ at license.bsd and license.lgpl. More details about the project: -- Repository: https://github.com/lkiesow/python-feedgen -- Documentation: https://lkiesow.github.io/python-feedgen/ -- Python Package Index: https://pypi.python.org/pypi/feedgen/ +- `Repository `_ +- `Documentation `_ +- `Python Package Index `_ ------------ @@ -50,18 +50,20 @@ Create a Feed ------------- To create a feed simply instantiate the FeedGenerator class and insert some -data:: - - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() - >>> fg.id('http://lernfunk.de/media/654321') - >>> fg.title('Some Testfeed') - >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) - >>> fg.link( href='http://example.com', rel='alternate' ) - >>> fg.logo('http://ex.com/logo.jpg') - >>> fg.subtitle('This is a cool feed!') - >>> fg.link( href='http://larskiesow.de/test.atom', rel='self' ) - >>> fg.language('en') +data: + +.. code-block:: python + + from feedgen.feed import FeedGenerator + fg = FeedGenerator() + fg.id('http://lernfunk.de/media/654321') + fg.title('Some Testfeed') + fg.author( {'name':'John Doe','email':'john@example.de'} ) + fg.link( href='http://example.com', rel='alternate' ) + fg.logo('http://ex.com/logo.jpg') + fg.subtitle('This is a cool feed!') + fg.link( href='http://larskiesow.de/test.atom', rel='self' ) + fg.language('en') Note that for the methods which set fields that can occur more than once in a feed you can use all of the following ways to provide data: @@ -70,22 +72,26 @@ feed you can use all of the following ways to provide data: - Provide the data for that element as dictionary - Provide a list of dictionaries with the data for several elements -Example:: +Example: - >>> fg.contributor( name='John Doe', email='jdoe@example.com' ) - >>> fg.contributor({'name':'John Doe', 'email':'jdoe@example.com'}) - >>> fg.contributor([{'name':'John Doe', 'email':'jdoe@example.com'}, ...]) +.. code-block:: python + + fg.contributor( name='John Doe', email='jdoe@example.com' ) + fg.contributor({'name':'John Doe', 'email':'jdoe@example.com'}) + fg.contributor([{'name':'John Doe', 'email':'jdoe@example.com'}, ...]) ----------------- Generate the Feed ----------------- -After that you can generate both RSS or ATOM by calling the respective method:: +After that you can generate both RSS or ATOM by calling the respective method: + +.. code-block:: python - >>> atomfeed = fg.atom_str(pretty=True) # Get the ATOM feed as string - >>> rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string - >>> fg.atom_file('atom.xml') # Write the ATOM feed to a file - >>> fg.rss_file('rss.xml') # Write the RSS feed to a file + atomfeed = fg.atom_str(pretty=True) # Get the ATOM feed as string + rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string + fg.atom_file('atom.xml') # Write the ATOM feed to a file + fg.rss_file('rss.xml') # Write the RSS feed to a file ---------------- @@ -95,31 +101,35 @@ Add Feed Entries To add entries (items) to a feed you need to create new FeedEntry objects and append them to the list of entries in the FeedGenerator. The most convenient way to go is to use the FeedGenerator itself for the instantiation of the -FeedEntry object:: +FeedEntry object: - >>> fe = fg.add_entry() - >>> fe.id('http://lernfunk.de/media/654321/1') - >>> fe.title('The First Episode') - >>> fe.link(href="http://lernfunk.de/feed") +.. code-block:: python -The FeedGenerators method `add_entry(...)` without argument provides will -automatically generate a new FeedEntry object, append it to the feeds internal -list of entries and return it, so that additional data can be added. + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1') + fe.title('The First Episode') + fe.link(href="http://lernfunk.de/feed") + +The FeedGenerator's method `add_entry(...)` will generate a new FeedEntry +object, automatically append it to the feeds internal list of entries and +return it, so that additional data can be added. ---------- Extensions ---------- -The FeedGenerator supports extension to include additional data into the XML -structure of the feeds. Extensions can be loaded like this:: +The FeedGenerator supports extensions to include additional data into the XML +structure of the feeds. Extensions can be loaded like this: + +.. code-block:: python - >>> fg.load_extension('someext', atom=True, rss=True) + fg.load_extension('someext', atom=True, rss=True) -This will try to load the extension “someext” from the file `ext/someext.py`. -It is required that `someext.py` contains a class named “SomextExtension” which -is required to have at least the two methods `extend_rss(...)` and -`extend_atom(...)`. Although not required, it is strongly suggested to use -`BaseExtension` from `ext/base.py` as superclass. +This example would try to load the extension “someext” from the file +`ext/someext.py`. It is required that `someext.py` contains a class named +“SomextExtension” which is required to have at least the two methods +`extend_rss(...)` and `extend_atom(...)`. Although not required, it is strongly +suggested to use `BaseExtension` from `ext/base.py` as superclass. `load_extension('someext', ...)` will also try to load a class named “SomextEntryExtension” for every entry of the feed. This class can be located @@ -127,7 +137,7 @@ either in the same file as SomextExtension or in `ext/someext_entry.py` which is suggested especially for large extensions. The parameters `atom` and `rss` control if the extension is used for ATOM and -RSS feeds, respectively. The default value for both parameters is `true` +RSS feeds respectively. The default value for both parameters is `True`, meaning the extension is used for both kinds of feeds. **Example: Producing a Podcast** @@ -135,22 +145,24 @@ meaning the extension is used for both kinds of feeds. One extension already provided is the podcast extension. A podcast is an RSS feed with some additional elements for ITunes. -To produce a podcast simply load the `podcast` extension:: +To produce a podcast simply load the `podcast` extension: + +.. code-block:: python - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() - >>> fg.load_extension('podcast') + from feedgen.feed import FeedGenerator + fg = FeedGenerator() + fg.load_extension('podcast') ... - >>> fg.podcast.itunes_category('Technology', 'Podcasting') + fg.podcast.itunes_category('Technology', 'Podcasting') ... - >>> fe = fg.add_entry() - >>> fe.id('http://lernfunk.de/media/654321/1/file.mp3') - >>> fe.title('The First Episode') - >>> fe.description('Enjoy our first episode.') - >>> fe.enclosure('http://lernfunk.de/media/654321/1/file.mp3', 0, 'audio/mpeg') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1/file.mp3') + fe.title('The First Episode') + fe.description('Enjoy our first episode.') + fe.enclosure('http://lernfunk.de/media/654321/1/file.mp3', 0, 'audio/mpeg') ... - >>> fg.rss_str(pretty=True) - >>> fg.rss_file('podcast.xml') + fg.rss_str(pretty=True) + fg.rss_file('podcast.xml') If the FeedGenerator class is used to load an extension, it is automatically loaded for every feed entry as well. You can, however, load an extension for a From 9440ccaffe60259d7040306d0d97da9310b11d57 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 22:27:58 +0100 Subject: [PATCH 25/47] Update Python Versions This patch updates the Python versions to test against, dropping the now officially unsupported Python 2.7. --- .travis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 652fae3..80721da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,9 +4,9 @@ dist: bionic # https://devguide.python.org/#branchstatus python: - - "2.7" - - "3.6" - - "3.7" + - 3.6 + - 3.7 + - 3.8 install: - pip install flake8 python-coveralls coverage liccheck From 0eb12f9133ec2f977b88b0f8c07b3249b5b7051d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 15:58:49 +0100 Subject: [PATCH 26/47] Prevent XML Denial of Service Attacks This patch prevents entity expansion for provided XML content to guard against XML denial of service attacks like XML bomb or Billion laughs attack. --- feedgen/entry.py | 93 ++++++++++++++-------------- feedgen/ext/dc.py | 13 ++-- feedgen/ext/geo_entry.py | 28 ++++----- feedgen/ext/media.py | 12 ++-- feedgen/ext/podcast.py | 33 +++++----- feedgen/ext/podcast_entry.py | 23 ++++--- feedgen/ext/syndication.py | 5 +- feedgen/ext/torrent.py | 18 +++--- feedgen/feed.py | 113 +++++++++++++++++------------------ feedgen/util.py | 22 +++++++ 10 files changed, 180 insertions(+), 180 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 8617bb2..66400ba 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -3,7 +3,7 @@ feedgen.entry ~~~~~~~~~~~~~ - :copyright: 2013, Lars Kiesow + :copyright: 2013-2020, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' @@ -13,10 +13,11 @@ import dateutil.parser import dateutil.tz import warnings -from lxml import etree + +from lxml.etree import CDATA # nosec - adding CDATA entry is safe from feedgen.compat import string_types -from feedgen.util import ensure_format, formatRFC2822 +from feedgen.util import ensure_format, formatRFC2822, xml_fromstring, xml_elem def _add_text_elm(entry, data, name): @@ -24,7 +25,7 @@ def _add_text_elm(entry, data, name): if not data: return - elm = etree.SubElement(entry, name) + elm = xml_elem(name, entry) type_ = data.get('type') if data.get('src'): if name != 'content': @@ -34,16 +35,14 @@ def _add_text_elm(entry, data, name): elif data.get(name): # Surround xhtml with a div tag, parse it and embed it if type_ == 'xhtml': - elm.append(etree.fromstring( - '
' + - data.get(name) + '
')) + xhtml = '
' \ + + data.get(name) + '
' + elm.append(xml_fromstring(xhtml)) elif type_ == 'CDATA': - elm.text = etree.CDATA( - data.get(name)) + elm.text = CDATA(data.get(name)) # Parse XML and embed it elif type_ and (type_.endswith('/xml') or type_.endswith('+xml')): - elm.append(etree.fromstring( - data[name])) + elm.append(xml_fromstring(data[name])) # Embed the text in escaped form elif not type_ or type_.startswith('text') or type_ == 'html': elm.text = data.get(name) @@ -102,14 +101,14 @@ def __init__(self): def atom_entry(self, extensions=True): '''Create an ATOM entry and return it.''' - entry = etree.Element('entry') + entry = xml_elem('entry') if not (self.__atom_id and self.__atom_title and self.__atom_updated): raise ValueError('Required fields not set') - id = etree.SubElement(entry, 'id') + id = xml_elem('id', entry) id.text = self.__atom_id - title = etree.SubElement(entry, 'title') + title = xml_elem('title', entry) title.text = self.__atom_title - updated = etree.SubElement(entry, 'updated') + updated = xml_elem('updated', entry) updated.text = self.__atom_updated.isoformat() # An entry must contain an alternate link if there is no content @@ -125,20 +124,20 @@ def atom_entry(self, extensions=True): # Atom requires a name. Skip elements without. if not a.get('name'): continue - author = etree.SubElement(entry, 'author') - name = etree.SubElement(author, 'name') + author = xml_elem('author', entry) + name = xml_elem('name', author) name.text = a.get('name') if a.get('email'): - email = etree.SubElement(author, 'email') + email = xml_elem('email', author) email.text = a.get('email') if a.get('uri'): - uri = etree.SubElement(author, 'uri') + uri = xml_elem('uri', author) uri.text = a.get('uri') _add_text_elm(entry, self.__atom_content, 'content') for l in self.__atom_link or []: - link = etree.SubElement(entry, 'link', href=l['href']) + link = xml_elem('link', entry, href=l['href']) if l.get('rel'): link.attrib['rel'] = l['rel'] if l.get('type'): @@ -153,7 +152,7 @@ def atom_entry(self, extensions=True): _add_text_elm(entry, self.__atom_summary, 'summary') for c in self.__atom_category or []: - cat = etree.SubElement(entry, 'category', term=c['term']) + cat = xml_elem('category', entry, term=c['term']) if c.get('scheme'): cat.attrib['scheme'] = c['scheme'] if c.get('label'): @@ -164,32 +163,31 @@ def atom_entry(self, extensions=True): # Atom requires a name. Skip elements without. if not c.get('name'): continue - contrib = etree.SubElement(entry, 'contributor') - name = etree.SubElement(contrib, 'name') + contrib = xml_elem('contributor', entry) + name = xml_elem('name', contrib) name.text = c.get('name') if c.get('email'): - email = etree.SubElement(contrib, 'email') + email = xml_elem('email', contrib) email.text = c.get('email') if c.get('uri'): - uri = etree.SubElement(contrib, 'uri') + uri = xml_elem('uri', contrib) uri.text = c.get('uri') if self.__atom_published: - published = etree.SubElement(entry, 'published') + published = xml_elem('published', entry) published.text = self.__atom_published.isoformat() if self.__atom_rights: - rights = etree.SubElement(entry, 'rights') + rights = xml_elem('rights', entry) rights.text = self.__atom_rights if self.__atom_source: - source = etree.SubElement(entry, 'source') + source = xml_elem('source', entry) if self.__atom_source.get('title'): - source_title = etree.SubElement(source, 'title') + source_title = xml_elem('title', source) source_title.text = self.__atom_source['title'] if self.__atom_source.get('link'): - etree.SubElement(source, 'link', - href=self.__atom_source['link']) + xml_elem('link', source, href=self.__atom_source['link']) if extensions: for ext in self.__extensions.values() or []: @@ -200,60 +198,59 @@ def atom_entry(self, extensions=True): def rss_entry(self, extensions=True): '''Create a RSS item and return it.''' - entry = etree.Element('item') + entry = xml_elem('item') if not (self.__rss_title or self.__rss_description or self.__rss_content): raise ValueError('Required fields not set') if self.__rss_title: - title = etree.SubElement(entry, 'title') + title = xml_elem('title', entry) title.text = self.__rss_title if self.__rss_link: - link = etree.SubElement(entry, 'link') + link = xml_elem('link', entry) link.text = self.__rss_link if self.__rss_description and self.__rss_content: - description = etree.SubElement(entry, 'description') + description = xml_elem('description', entry) description.text = self.__rss_description XMLNS_CONTENT = 'http://purl.org/rss/1.0/modules/content/' - content = etree.SubElement(entry, '{%s}encoded' % XMLNS_CONTENT) - content.text = etree.CDATA(self.__rss_content['content']) \ + content = xml_elem('{%s}encoded' % XMLNS_CONTENT, entry) + content.text = CDATA(self.__rss_content['content']) \ if self.__rss_content.get('type', '') == 'CDATA' \ else self.__rss_content['content'] elif self.__rss_description: - description = etree.SubElement(entry, 'description') + description = xml_elem('description', entry) description.text = self.__rss_description elif self.__rss_content: - description = etree.SubElement(entry, 'description') - description.text = etree.CDATA(self.__rss_content['content']) \ + description = xml_elem('description', entry) + description.text = CDATA(self.__rss_content['content']) \ if self.__rss_content.get('type', '') == 'CDATA' \ else self.__rss_content['content'] for a in self.__rss_author or []: - author = etree.SubElement(entry, 'author') + author = xml_elem('author', entry) author.text = a if self.__rss_guid.get('guid'): - guid = etree.SubElement(entry, 'guid') + guid = xml_elem('guid', entry) guid.text = self.__rss_guid['guid'] permaLink = str(self.__rss_guid.get('permalink', False)).lower() guid.attrib['isPermaLink'] = permaLink for cat in self.__rss_category or []: - category = etree.SubElement(entry, 'category') + category = xml_elem('category', entry) category.text = cat['value'] if cat.get('domain'): category.attrib['domain'] = cat['domain'] if self.__rss_comments: - comments = etree.SubElement(entry, 'comments') + comments = xml_elem('comments', entry) comments.text = self.__rss_comments if self.__rss_enclosure: - enclosure = etree.SubElement(entry, 'enclosure') + enclosure = xml_elem('enclosure', entry) enclosure.attrib['url'] = self.__rss_enclosure['url'] enclosure.attrib['length'] = self.__rss_enclosure['length'] enclosure.attrib['type'] = self.__rss_enclosure['type'] if self.__rss_pubDate: - pubDate = etree.SubElement(entry, 'pubDate') + pubDate = xml_elem('pubDate', entry) pubDate.text = formatRFC2822(self.__rss_pubDate) if self.__rss_source: - source = etree.SubElement(entry, 'source', - url=self.__rss_source['url']) + source = xml_elem('source', entry, url=self.__rss_source['url']) source.text = self.__rss_source['title'] if extensions: diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index bc4cb7f..f731c0b 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -13,9 +13,8 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.ext.base import BaseExtension +from feedgen.util import xml_elem class DcBaseExtension(BaseExtension): @@ -45,10 +44,10 @@ def __init__(self): def extend_ns(self): return {'dc': 'http://purl.org/dc/elements/1.1/'} - def _extend_xml(self, xml_elem): - '''Extend xml_elem with set DC fields. + def _extend_xml(self, xml_element): + '''Extend xml_element with set DC fields. - :param xml_elem: etree element + :param xml_element: etree element ''' DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/' @@ -58,8 +57,8 @@ def _extend_xml(self, xml_elem): 'identifier']: if hasattr(self, '_dcelem_%s' % elem): for val in getattr(self, '_dcelem_%s' % elem) or []: - node = etree.SubElement(xml_elem, - '{%s}%s' % (DCELEMENTS_NS, elem)) + node = xml_elem('{%s}%s' % (DCELEMENTS_NS, elem), + xml_element) node.text = val def extend_atom(self, atom_feed): diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 2ad6611..bb06cc2 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -12,8 +12,8 @@ import numbers import warnings -from lxml import etree from feedgen.ext.base import BaseEntryExtension +from feedgen.util import xml_elem class GeoRSSPolygonInteriorWarning(Warning): @@ -86,49 +86,43 @@ def extend_file(self, entry): GEO_NS = 'http://www.georss.org/georss' if self.__point: - point = etree.SubElement(entry, '{%s}point' % GEO_NS) + point = xml_elem('{%s}point' % GEO_NS, entry) point.text = self.__point if self.__line: - line = etree.SubElement(entry, '{%s}line' % GEO_NS) + line = xml_elem('{%s}line' % GEO_NS, entry) line.text = self.__line if self.__polygon: - polygon = etree.SubElement(entry, '{%s}polygon' % GEO_NS) + polygon = xml_elem('{%s}polygon' % GEO_NS, entry) polygon.text = self.__polygon if self.__box: - box = etree.SubElement(entry, '{%s}box' % GEO_NS) + box = xml_elem('{%s}box' % GEO_NS, entry) box.text = self.__box if self.__featuretypetag: - featuretypetag = etree.SubElement( - entry, - '{%s}featuretypetag' % GEO_NS - ) + featuretypetag = xml_elem('{%s}featuretypetag' % GEO_NS, entry) featuretypetag.text = self.__featuretypetag if self.__relationshiptag: - relationshiptag = etree.SubElement( - entry, - '{%s}relationshiptag' % GEO_NS - ) + relationshiptag = xml_elem('{%s}relationshiptag' % GEO_NS, entry) relationshiptag.text = self.__relationshiptag if self.__featurename: - featurename = etree.SubElement(entry, '{%s}featurename' % GEO_NS) + featurename = xml_elem('{%s}featurename' % GEO_NS, entry) featurename.text = self.__featurename if self.__elev: - elevation = etree.SubElement(entry, '{%s}elev' % GEO_NS) + elevation = xml_elem('{%s}elev' % GEO_NS, entry) elevation.text = str(self.__elev) if self.__floor: - floor = etree.SubElement(entry, '{%s}floor' % GEO_NS) + floor = xml_elem('{%s}floor' % GEO_NS, entry) floor.text = str(self.__floor) if self.__radius: - radius = etree.SubElement(entry, '{%s}radius' % GEO_NS) + radius = xml_elem('{%s}radius' % GEO_NS, entry) radius.text = str(self.__radius) return entry diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py index 25d561a..74a5317 100644 --- a/feedgen/ext/media.py +++ b/feedgen/ext/media.py @@ -10,10 +10,8 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.ext.base import BaseEntryExtension, BaseExtension -from feedgen.util import ensure_format +from feedgen.util import ensure_format, xml_elem MEDIA_NS = 'http://search.yahoo.com/mrss/' @@ -45,10 +43,10 @@ def extend_atom(self, entry): # Define current media:group group = groups.get(media_content.get('group')) if group is None: - group = etree.SubElement(entry, '{%s}group' % MEDIA_NS) + group = xml_elem('{%s}group' % MEDIA_NS, entry) groups[media_content.get('group')] = group # Add content - content = etree.SubElement(group, '{%s}content' % MEDIA_NS) + content = xml_elem('{%s}content' % MEDIA_NS, group) for attr in ('url', 'fileSize', 'type', 'medium', 'isDefault', 'expression', 'bitrate', 'framerate', 'samplingrate', 'channels', 'duration', 'height', 'width', 'lang'): @@ -59,10 +57,10 @@ def extend_atom(self, entry): # Define current media:group group = groups.get(media_thumbnail.get('group')) if group is None: - group = etree.SubElement(entry, '{%s}group' % MEDIA_NS) + group = xml_elem('{%s}group' % MEDIA_NS, entry) groups[media_thumbnail.get('group')] = group # Add thumbnails - thumbnail = etree.SubElement(group, '{%s}thumbnail' % MEDIA_NS) + thumbnail = xml_elem('{%s}thumbnail' % MEDIA_NS, group) for attr in ('url', 'height', 'width', 'time'): if media_thumbnail.get(attr): thumbnail.set(attr, media_thumbnail[attr]) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index a8af118..4c7eb0b 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -10,11 +10,9 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.compat import string_types from feedgen.ext.base import BaseExtension -from feedgen.util import ensure_format +from feedgen.util import ensure_format, xml_elem class PodcastExtension(BaseExtension): @@ -47,11 +45,11 @@ def extend_rss(self, rss_feed): channel = rss_feed[0] if self.__itunes_author: - author = etree.SubElement(channel, '{%s}author' % ITUNES_NS) + author = xml_elem('{%s}author' % ITUNES_NS, channel) author.text = self.__itunes_author if self.__itunes_block is not None: - block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) + block = xml_elem('{%s}block' % ITUNES_NS, channel) block.text = 'yes' if self.__itunes_block else 'no' for c in self.__itunes_category or []: @@ -60,45 +58,42 @@ def extend_rss(self, rss_feed): category = channel.find( '{%s}category[@text="%s"]' % (ITUNES_NS, c.get('cat'))) if category is None: - category = etree.SubElement(channel, - '{%s}category' % ITUNES_NS) + category = xml_elem('{%s}category' % ITUNES_NS, channel) category.attrib['text'] = c.get('cat') if c.get('sub'): - subcategory = etree.SubElement(category, - '{%s}category' % ITUNES_NS) + subcategory = xml_elem('{%s}category' % ITUNES_NS, category) subcategory.attrib['text'] = c.get('sub') if self.__itunes_image: - image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) + image = xml_elem('{%s}image' % ITUNES_NS, channel) image.attrib['href'] = self.__itunes_image if self.__itunes_explicit in ('yes', 'no', 'clean'): - explicit = etree.SubElement(channel, '{%s}explicit' % ITUNES_NS) + explicit = xml_elem('{%s}explicit' % ITUNES_NS, channel) explicit.text = self.__itunes_explicit if self.__itunes_complete in ('yes', 'no'): - complete = etree.SubElement(channel, '{%s}complete' % ITUNES_NS) + complete = xml_elem('{%s}complete' % ITUNES_NS, channel) complete.text = self.__itunes_complete if self.__itunes_new_feed_url: - new_feed_url = etree.SubElement(channel, - '{%s}new-feed-url' % ITUNES_NS) + new_feed_url = xml_elem('{%s}new-feed-url' % ITUNES_NS, channel) new_feed_url.text = self.__itunes_new_feed_url if self.__itunes_owner: - owner = etree.SubElement(channel, '{%s}owner' % ITUNES_NS) - owner_name = etree.SubElement(owner, '{%s}name' % ITUNES_NS) + owner = xml_elem('{%s}owner' % ITUNES_NS, channel) + owner_name = xml_elem('{%s}name' % ITUNES_NS, owner) owner_name.text = self.__itunes_owner.get('name') - owner_email = etree.SubElement(owner, '{%s}email' % ITUNES_NS) + owner_email = xml_elem('{%s}email' % ITUNES_NS, owner) owner_email.text = self.__itunes_owner.get('email') if self.__itunes_subtitle: - subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) + subtitle = xml_elem('{%s}subtitle' % ITUNES_NS, channel) subtitle.text = self.__itunes_subtitle if self.__itunes_summary: - summary = etree.SubElement(channel, '{%s}summary' % ITUNES_NS) + summary = xml_elem('{%s}summary' % ITUNES_NS, channel) summary.text = self.__itunes_summary return rss_feed diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 4fa6128..2a3771f 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -10,9 +10,8 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.ext.base import BaseEntryExtension +from feedgen.util import xml_elem class PodcastEntryExtension(BaseEntryExtension): @@ -40,43 +39,43 @@ def extend_rss(self, entry): ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' if self.__itunes_author: - author = etree.SubElement(entry, '{%s}author' % ITUNES_NS) + author = xml_elem('{%s}author' % ITUNES_NS, entry) author.text = self.__itunes_author if self.__itunes_block is not None: - block = etree.SubElement(entry, '{%s}block' % ITUNES_NS) + block = xml_elem('{%s}block' % ITUNES_NS, entry) block.text = 'yes' if self.__itunes_block else 'no' if self.__itunes_image: - image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) + image = xml_elem('{%s}image' % ITUNES_NS, entry) image.attrib['href'] = self.__itunes_image if self.__itunes_duration: - duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) + duration = xml_elem('{%s}duration' % ITUNES_NS, entry) duration.text = self.__itunes_duration if self.__itunes_explicit in ('yes', 'no', 'clean'): - explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) + explicit = xml_elem('{%s}explicit' % ITUNES_NS, entry) explicit.text = self.__itunes_explicit if self.__itunes_is_closed_captioned is not None: - is_closed_captioned = etree.SubElement( - entry, '{%s}isClosedCaptioned' % ITUNES_NS) + is_closed_captioned = xml_elem( + '{%s}isClosedCaptioned' % ITUNES_NS, entry) if self.__itunes_is_closed_captioned: is_closed_captioned.text = 'yes' else: is_closed_captioned.text = 'no' if self.__itunes_order is not None and self.__itunes_order >= 0: - order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) + order = xml_elem('{%s}order' % ITUNES_NS, entry) order.text = str(self.__itunes_order) if self.__itunes_subtitle: - subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) + subtitle = xml_elem('{%s}subtitle' % ITUNES_NS, entry) subtitle.text = self.__itunes_subtitle if self.__itunes_summary: - summary = etree.SubElement(entry, '{%s}summary' % ITUNES_NS) + summary = xml_elem('{%s}summary' % ITUNES_NS, entry) summary.text = self.__itunes_summary return entry diff --git a/feedgen/ext/syndication.py b/feedgen/ext/syndication.py index 0141369..016b144 100644 --- a/feedgen/ext/syndication.py +++ b/feedgen/ext/syndication.py @@ -10,9 +10,8 @@ http://web.resource.org/rss/1.0/modules/syndication/ ''' -from lxml import etree - from feedgen.ext.base import BaseExtension +from feedgen.util import xml_elem SYNDICATION_NS = 'http://purl.org/rss/1.0/modules/syndication/' PERIOD_TYPE = ('hourly', 'daily', 'weekly', 'monthly', 'yearly') @@ -20,7 +19,7 @@ def _set_value(channel, name, value): if value: - newelem = etree.SubElement(channel, '{%s}' % SYNDICATION_NS + name) + newelem = xml_elem('{%s}' % SYNDICATION_NS + name, channel) newelem.text = value diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index e26d0bb..5548a81 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -10,9 +10,8 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.ext.base import BaseEntryExtension, BaseExtension +from feedgen.util import xml_elem TORRENT_NS = 'http://xmlns.ezrss.it/0.1/dtd/' @@ -41,30 +40,29 @@ def extend_rss(self, entry): :param feed: The RSS item XML element to use. ''' if self.__torrent_filename: - filename = etree.SubElement(entry, '{%s}filename' % TORRENT_NS) + filename = xml_elem('{%s}filename' % TORRENT_NS, entry) filename.text = self.__torrent_filename if self.__torrent_contentlength: - contentlength = etree.SubElement(entry, - '{%s}contentlength' % TORRENT_NS) + contentlength = xml_elem('{%s}contentlength' % TORRENT_NS, entry) contentlength.text = self.__torrent_contentlength if self.__torrent_infohash: - infohash = etree.SubElement(entry, '{%s}infohash' % TORRENT_NS) + infohash = xml_elem('{%s}infohash' % TORRENT_NS, entry) infohash.text = self.__torrent_infohash - magnet = etree.SubElement(entry, '{%s}magneturi' % TORRENT_NS) + magnet = xml_elem('{%s}magneturi' % TORRENT_NS, entry) magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_infohash if self.__torrent_seeds: - seeds = etree.SubElement(entry, '{%s}seed' % TORRENT_NS) + seeds = xml_elem('{%s}seed' % TORRENT_NS, entry) seeds.text = self.__torrent_seeds if self.__torrent_peers: - peers = etree.SubElement(entry, '{%s}peers' % TORRENT_NS) + peers = xml_elem('{%s}peers' % TORRENT_NS, entry) peers.text = self.__torrent_peers if self.__torrent_verified: - verified = etree.SubElement(entry, '{%s}verified' % TORRENT_NS) + verified = xml_elem('{%s}verified' % TORRENT_NS, entry) verified.text = self.__torrent_verified def filename(self, torrent_filename=None): diff --git a/feedgen/feed.py b/feedgen/feed.py index b2a206f..9ebd219 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -3,7 +3,7 @@ feedgen.feed ~~~~~~~~~~~~ - :copyright: 2013-2016, Lars Kiesow + :copyright: 2013-2020, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. @@ -14,12 +14,12 @@ import dateutil.parser import dateutil.tz -from lxml import etree +from lxml import etree # nosec - not using this for parsing import feedgen.version from feedgen.compat import string_types from feedgen.entry import FeedEntry -from feedgen.util import ensure_format, formatRFC2822 +from feedgen.util import ensure_format, formatRFC2822, xml_elem _feedgen_version = feedgen.version.version_str @@ -47,7 +47,7 @@ def __init__(self): self.__atom_contributor = None self.__atom_generator = { 'value': 'python-feedgen', - 'uri': 'http://lkiesow.github.io/python-feedgen', + 'uri': 'https://lkiesow.github.io/python-feedgen', 'version': feedgen.version.version_str} # {value*,uri,version} self.__atom_icon = None self.__atom_logo = None @@ -95,9 +95,9 @@ def _create_atom(self, extensions=True): if ext.get('atom'): nsmap.update(ext['inst'].extend_ns()) - feed = etree.Element('feed', - xmlns='http://www.w3.org/2005/Atom', - nsmap=nsmap) + feed = xml_elem('feed', + xmlns='http://www.w3.org/2005/Atom', + nsmap=nsmap) if self.__atom_feed_xml_lang: feed.attrib['{http://www.w3.org/XML/1998/namespace}lang'] = \ self.__atom_feed_xml_lang @@ -108,11 +108,11 @@ def _create_atom(self, extensions=True): ([] if self.__atom_updated else ['updated']) missing = ', '.join(missing) raise ValueError('Required fields not set (%s)' % missing) - id = etree.SubElement(feed, 'id') + id = xml_elem('id', feed) id.text = self.__atom_id - title = etree.SubElement(feed, 'title') + title = xml_elem('title', feed) title.text = self.__atom_title - updated = etree.SubElement(feed, 'updated') + updated = xml_elem('updated', feed) updated.text = self.__atom_updated.isoformat() # Add author elements @@ -120,18 +120,18 @@ def _create_atom(self, extensions=True): # Atom requires a name. Skip elements without. if not a.get('name'): continue - author = etree.SubElement(feed, 'author') - name = etree.SubElement(author, 'name') + author = xml_elem('author', feed) + name = xml_elem('name', author) name.text = a.get('name') if a.get('email'): - email = etree.SubElement(author, 'email') + email = xml_elem('email', author) email.text = a.get('email') if a.get('uri'): - uri = etree.SubElement(author, 'uri') + uri = xml_elem('uri', author) uri.text = a.get('uri') for l in self.__atom_link or []: - link = etree.SubElement(feed, 'link', href=l['href']) + link = xml_elem('link', feed, href=l['href']) if l.get('rel'): link.attrib['rel'] = l['rel'] if l.get('type'): @@ -144,7 +144,7 @@ def _create_atom(self, extensions=True): link.attrib['length'] = l['length'] for c in self.__atom_category or []: - cat = etree.SubElement(feed, 'category', term=c['term']) + cat = xml_elem('category', feed, term=c['term']) if c.get('scheme'): cat.attrib['scheme'] = c['scheme'] if c.get('label'): @@ -155,18 +155,18 @@ def _create_atom(self, extensions=True): # Atom requires a name. Skip elements without. if not c.get('name'): continue - contrib = etree.SubElement(feed, 'contributor') - name = etree.SubElement(contrib, 'name') + contrib = xml_elem('contributor', feed) + name = xml_elem('name', contrib) name.text = c.get('name') if c.get('email'): - email = etree.SubElement(contrib, 'email') + email = xml_elem('email', contrib) email.text = c.get('email') if c.get('uri'): - uri = etree.SubElement(contrib, 'uri') + uri = xml_elem('uri', contrib) uri.text = c.get('uri') if self.__atom_generator and self.__atom_generator.get('value'): - generator = etree.SubElement(feed, 'generator') + generator = xml_elem('generator', feed) generator.text = self.__atom_generator['value'] if self.__atom_generator.get('uri'): generator.attrib['uri'] = self.__atom_generator['uri'] @@ -174,19 +174,19 @@ def _create_atom(self, extensions=True): generator.attrib['version'] = self.__atom_generator['version'] if self.__atom_icon: - icon = etree.SubElement(feed, 'icon') + icon = xml_elem('icon', feed) icon.text = self.__atom_icon if self.__atom_logo: - logo = etree.SubElement(feed, 'logo') + logo = xml_elem('logo', feed) logo.text = self.__atom_logo if self.__atom_rights: - rights = etree.SubElement(feed, 'rights') + rights = xml_elem('rights', feed) rights.text = self.__atom_rights if self.__atom_subtitle: - subtitle = etree.SubElement(feed, 'subtitle') + subtitle = xml_elem('subtitle', feed) subtitle.text = self.__atom_subtitle if extensions: @@ -255,8 +255,8 @@ def _create_rss(self, extensions=True): nsmap.update({'atom': 'http://www.w3.org/2005/Atom', 'content': 'http://purl.org/rss/1.0/modules/content/'}) - feed = etree.Element('rss', version='2.0', nsmap=nsmap) - channel = etree.SubElement(feed, 'channel') + feed = xml_elem('rss', version='2.0', nsmap=nsmap) + channel = xml_elem('channel', feed) if not (self.__rss_title and self.__rss_link and self.__rss_description): @@ -265,18 +265,17 @@ def _create_rss(self, extensions=True): ([] if self.__rss_description else ['description']) missing = ', '.join(missing) raise ValueError('Required fields not set (%s)' % missing) - title = etree.SubElement(channel, 'title') + title = xml_elem('title', channel) title.text = self.__rss_title - link = etree.SubElement(channel, 'link') + link = xml_elem('link', channel) link.text = self.__rss_link - desc = etree.SubElement(channel, 'description') + desc = xml_elem('description', channel) desc.text = self.__rss_description for ln in self.__atom_link or []: # It is recommended to include a atom self link in rss documents… if ln.get('rel') == 'self': - selflink = etree.SubElement( - channel, '{http://www.w3.org/2005/Atom}link', - href=ln['href'], rel='self') + selflink = xml_elem('{http://www.w3.org/2005/Atom}link', + channel, href=ln['href'], rel='self') if ln.get('type'): selflink.attrib['type'] = ln['type'] if ln.get('hreflang'): @@ -288,12 +287,12 @@ def _create_rss(self, extensions=True): break if self.__rss_category: for cat in self.__rss_category: - category = etree.SubElement(channel, 'category') + category = xml_elem('category', channel) category.text = cat['value'] if cat.get('domain'): category.attrib['domain'] = cat['domain'] if self.__rss_cloud: - cloud = etree.SubElement(channel, 'cloud') + cloud = xml_elem('cloud', channel) cloud.attrib['domain'] = self.__rss_cloud.get('domain') cloud.attrib['port'] = self.__rss_cloud.get('port') cloud.attrib['path'] = self.__rss_cloud.get('path') @@ -301,69 +300,69 @@ def _create_rss(self, extensions=True): 'registerProcedure') cloud.attrib['protocol'] = self.__rss_cloud.get('protocol') if self.__rss_copyright: - copyright = etree.SubElement(channel, 'copyright') + copyright = xml_elem('copyright', channel) copyright.text = self.__rss_copyright if self.__rss_docs: - docs = etree.SubElement(channel, 'docs') + docs = xml_elem('docs', channel) docs.text = self.__rss_docs if self.__rss_generator: - generator = etree.SubElement(channel, 'generator') + generator = xml_elem('generator', channel) generator.text = self.__rss_generator if self.__rss_image: - image = etree.SubElement(channel, 'image') - url = etree.SubElement(image, 'url') + image = xml_elem('image', channel) + url = xml_elem('url', image) url.text = self.__rss_image.get('url') - title = etree.SubElement(image, 'title') + title = xml_elem('title', image) title.text = self.__rss_image.get('title', self.__rss_title) - link = etree.SubElement(image, 'link') + link = xml_elem('link', image) link.text = self.__rss_image.get('link', self.__rss_link) if self.__rss_image.get('width'): - width = etree.SubElement(image, 'width') + width = xml_elem('width', image) width.text = self.__rss_image.get('width') if self.__rss_image.get('height'): - height = etree.SubElement(image, 'height') + height = xml_elem('height', image) height.text = self.__rss_image.get('height') if self.__rss_image.get('description'): - description = etree.SubElement(image, 'description') + description = xml_elem('description', image) description.text = self.__rss_image.get('description') if self.__rss_language: - language = etree.SubElement(channel, 'language') + language = xml_elem('language', channel) language.text = self.__rss_language if self.__rss_lastBuildDate: - lastBuildDate = etree.SubElement(channel, 'lastBuildDate') + lastBuildDate = xml_elem('lastBuildDate', channel) lastBuildDate.text = formatRFC2822(self.__rss_lastBuildDate) if self.__rss_managingEditor: - managingEditor = etree.SubElement(channel, 'managingEditor') + managingEditor = xml_elem('managingEditor', channel) managingEditor.text = self.__rss_managingEditor if self.__rss_pubDate: - pubDate = etree.SubElement(channel, 'pubDate') + pubDate = xml_elem('pubDate', channel) pubDate.text = formatRFC2822(self.__rss_pubDate) if self.__rss_rating: - rating = etree.SubElement(channel, 'rating') + rating = xml_elem('rating', channel) rating.text = self.__rss_rating if self.__rss_skipHours: - skipHours = etree.SubElement(channel, 'skipHours') + skipHours = xml_elem('skipHours', channel) for h in self.__rss_skipHours: - hour = etree.SubElement(skipHours, 'hour') + hour = xml_elem('hour', skipHours) hour.text = str(h) if self.__rss_skipDays: - skipDays = etree.SubElement(channel, 'skipDays') + skipDays = xml_elem('skipDays', channel) for d in self.__rss_skipDays: - day = etree.SubElement(skipDays, 'day') + day = xml_elem('day', skipDays) day.text = d if self.__rss_textInput: - textInput = etree.SubElement(channel, 'textInput') + textInput = xml_elem('textInput', channel) textInput.attrib['title'] = self.__rss_textInput.get('title') textInput.attrib['description'] = \ self.__rss_textInput.get('description') textInput.attrib['name'] = self.__rss_textInput.get('name') textInput.attrib['link'] = self.__rss_textInput.get('link') if self.__rss_ttl: - ttl = etree.SubElement(channel, 'ttl') + ttl = xml_elem('ttl', channel) ttl.text = str(self.__rss_ttl) if self.__rss_webMaster: - webMaster = etree.SubElement(channel, 'webMaster') + webMaster = xml_elem('webMaster', channel) webMaster.text = self.__rss_webMaster if extensions: diff --git a/feedgen/util.py b/feedgen/util.py index ca4ad58..8b4e6e5 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -10,6 +10,28 @@ ''' import locale import sys +import lxml # nosec - we configure a safe parser below + +# Configure a safe parser which does not allow XML entity expansion +parser = lxml.etree.XMLParser( + attribute_defaults=False, + dtd_validation=False, + load_dtd=False, + no_network=True, + recover=False, + remove_pis=True, + resolve_entities=False, + huge_tree=False) + + +def xml_fromstring(xmlstring): + return lxml.etree.fromstring(xmlstring, parser) # nosec - safe parser + + +def xml_elem(name, parent=None, **kwargs): + if parent is not None: + return lxml.etree.SubElement(parent, name, **kwargs) + return lxml.etree.Element(name, **kwargs) def ensure_format(val, allowed, required, allowed_values=None, defaults=None): From 2b77fd58eb1fb230e3c80e2cc4c5b0c4d05b6150 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Tue, 28 Jan 2020 17:50:53 +0100 Subject: [PATCH 27/47] Introduce Bandit Security Linter This patch introduces the bandit security linter as part of the CI tests run on feedgen. --- .travis.yml | 2 +- Makefile | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 80721da..c58695e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ python: - 3.8 install: - - pip install flake8 python-coveralls coverage liccheck + - pip install bandit flake8 python-coveralls coverage liccheck - pip install -r requirements.txt - python setup.py bdist_wheel - pip install dist/feedgen* diff --git a/Makefile b/Makefile index 50c88d9..a4c34de 100644 --- a/Makefile +++ b/Makefile @@ -51,3 +51,4 @@ publish: test: coverage run --source=feedgen -m unittest discover -s tests flake8 $$(find setup.py tests feedgen -name '*.py') + bandit -r feedgen From ffe3e4d752ac76e23c879c35682c310c2b1ccb86 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Tue, 28 Jan 2020 17:58:13 +0100 Subject: [PATCH 28/47] Release 0.9.0 - Prevent XML Denial of Service Attacks - Make Generator Optional (Atom) - Properly Parse text/xml - Add support for html summaries for Atom feeds --- feedgen/version.py | 2 +- python-feedgen.spec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index 436c851..2a59ec0 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 8, 0) +version = (0, 9, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index eac1fcb..b2e6515 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %global pypi_name feedgen Name: python-%{pypi_name} -Version: 0.8.0 +Version: 0.9.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) From 50d2b275d7051f603c15404e37be14f3646bf605 Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Mon, 9 Mar 2020 10:14:24 +0100 Subject: [PATCH 29/47] Add itunes:season and itunes:episode tags --- feedgen/__main__.py | 2 ++ feedgen/ext/podcast_entry.py | 30 +++++++++++++++++++++++++++ tests/test_extensions/test_podcast.py | 4 ++++ 3 files changed, 36 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index abc0737..d64cdce 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -101,6 +101,8 @@ def main(): 'adipiscing elit. Verba tu fingas et ea ' + 'dicas, quae non sentias?') fe.podcast.itunes_author('Lars Kiesow') + fe.podcast.itunes_season(1) + fe.podcast.itunes_episode(1) print_enc(fg.rss_str(pretty=True)) elif arg == 'torrent': diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 2a3771f..cd746e3 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -30,6 +30,8 @@ def __init__(self): self.__itunes_order = None self.__itunes_subtitle = None self.__itunes_summary = None + self.__itunes_season = None + self.__itunes_episode = None def extend_rss(self, entry): '''Add additional fields to an RSS item. @@ -77,6 +79,14 @@ def extend_rss(self, entry): if self.__itunes_summary: summary = xml_elem('{%s}summary' % ITUNES_NS, entry) summary.text = self.__itunes_summary + + if self.__itunes_season: + season = xml_elem('{%s}season' % ITUNES_NS, entry) + season.text = str(self.__itunes_season) + + if self.__itunes_episode: + episode = xml_elem('{%s}episode' % ITUNES_NS, entry) + episode.text = str(self.__itunes_episode) return entry def itunes_author(self, itunes_author=None): @@ -242,3 +252,23 @@ def itunes_summary(self, itunes_summary=None): if itunes_summary is not None: self.__itunes_summary = itunes_summary return self.__itunes_summary + + def itunes_season(self, itunes_season=None): + '''Get or set the itunes:season value for the podcast episode. + + :param itunes_season: Season number of the podcast epiosode. + :returns: Season number of the podcast episode. + ''' + if itunes_season is not None: + self.__itunes_season = int(itunes_season) + return self.__itunes_season + + def itunes_episode(self, itunes_episode=None): + '''Get or set the itunes:episode value for the podcast episode. + + :param itunes_season: Episode number of the podcast epiosode. + :returns: Episode number of the podcast episode. + ''' + if itunes_episode is not None: + self.__itunes_episode = int(itunes_episode) + return self.__itunes_episode diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index a41c96c..6b985fa 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -78,6 +78,8 @@ def test_podcastEntryItems(self): fe.podcast.itunes_order(1) fe.podcast.itunes_subtitle('x') fe.podcast.itunes_summary('x') + fe.podcast.itunes_season(1) + fe.podcast.itunes_episode(1) assert fe.podcast.itunes_author() == 'Lars Kiesow' assert fe.podcast.itunes_block() == 'x' assert fe.podcast.itunes_duration() == '00:01:30' @@ -87,6 +89,8 @@ def test_podcastEntryItems(self): assert fe.podcast.itunes_order() == 1 assert fe.podcast.itunes_subtitle() == 'x' assert fe.podcast.itunes_summary() == 'x' + assert fe.podcast.itunes_season() == 1 + assert fe.podcast.itunes_episode() == 1 # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} From 7ecc1e089983de0ed3e2116e9c098067009abba2 Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Tue, 21 Apr 2020 11:27:11 +0200 Subject: [PATCH 30/47] Add itunes tag type --- feedgen/__main__.py | 1 + feedgen/ext/podcast.py | 33 +++++++++++++++++++++++++++ tests/test_extensions/test_podcast.py | 2 ++ 3 files changed, 36 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index d64cdce..855c8a7 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -100,6 +100,7 @@ def main(): fg.podcast.itunes_summary('Lorem ipsum dolor sit amet, consectetur ' + 'adipiscing elit. Verba tu fingas et ea ' + 'dicas, quae non sentias?') + fg.podcast.itunes_type('episodic') fe.podcast.itunes_author('Lars Kiesow') fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 4c7eb0b..d070944 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -32,6 +32,7 @@ def __init__(self): self.__itunes_owner = None self.__itunes_subtitle = None self.__itunes_summary = None + self.__itunes_type = None def extend_ns(self): return {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} @@ -96,6 +97,10 @@ def extend_rss(self, rss_feed): summary = xml_elem('{%s}summary' % ITUNES_NS, channel) summary.text = self.__itunes_summary + if self.__itunes_type: + type = xml_elem('{%s}type' % ITUNES_NS, channel) + type.text = self.__itunes_type + return rss_feed def itunes_author(self, itunes_author=None): @@ -318,6 +323,34 @@ def itunes_summary(self, itunes_summary=None): self.__itunes_summary = itunes_summary return self.__itunes_summary + def itunes_type(self, itunes_type=None): + '''Get or set the itunes:type value of the podcast. This tag should + be used to indicate the type of your podcast. + The two values for this tag are "episodic" and "serial". + + If your show is Serial you must use this tag. + + Specify episodic when episodes are intended to be consumed without any + specific order. Apple Podcasts will present newest episodes first and + display the publish date (required) of each episode. If organized into + seasons, the newest season will be presented first - otherwise, + episodes will be grouped by year published, newest first. + + Specify serial when episodes are intended to be consumed in sequential + order. Apple Podcasts will present the oldest episodes first and + display the episode numbers (required) of each episode. If organized + into seasons, the newest season will be presented first and + numbers must be given for each episode. + + :param itunes_type: The type of the podcast + :returns: type of the pdocast. + ''' + if itunes_type is not None: + if itunes_type not in ('episodic', 'serial'): + raise ValueError('Invalid value for type tag') + self.__itunes_type = itunes_type + return self.__itunes_type + _itunes_categories = { 'Arts': [ 'Design', 'Fashion & Beauty', 'Food', 'Literature', diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index 6b985fa..f81f773 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -52,6 +52,7 @@ def test_podcastItems(self): fg.podcast.itunes_image('x.png') fg.podcast.itunes_subtitle('x') fg.podcast.itunes_summary('x') + fg.podcast.itunes_type('episodic') assert fg.podcast.itunes_author() == 'Lars Kiesow' assert fg.podcast.itunes_block() == 'x' assert fg.podcast.itunes_complete() == 'no' @@ -59,6 +60,7 @@ def test_podcastItems(self): assert fg.podcast.itunes_image() == 'x.png' assert fg.podcast.itunes_subtitle() == 'x' assert fg.podcast.itunes_summary() == 'x' + assert fg.podcast.itunes_type() == 'episodic' # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} From 7582e7b1d6d73e32082248e7cd468252c7d05ff7 Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Tue, 21 Apr 2020 11:48:49 +0200 Subject: [PATCH 31/47] Add tag for itunes_title --- feedgen/__main__.py | 1 + feedgen/ext/podcast_entry.py | 19 +++++++++++++++++++ tests/test_extensions/test_podcast.py | 2 ++ 3 files changed, 22 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 855c8a7..77da67b 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -104,6 +104,7 @@ def main(): fe.podcast.itunes_author('Lars Kiesow') fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) + fe.podcast.itunes_title('First podcast episode') print_enc(fg.rss_str(pretty=True)) elif arg == 'torrent': diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index cd746e3..2d201b5 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -32,6 +32,7 @@ def __init__(self): self.__itunes_summary = None self.__itunes_season = None self.__itunes_episode = None + self.__itunes_title = None def extend_rss(self, entry): '''Add additional fields to an RSS item. @@ -87,6 +88,10 @@ def extend_rss(self, entry): if self.__itunes_episode: episode = xml_elem('{%s}episode' % ITUNES_NS, entry) episode.text = str(self.__itunes_episode) + + if self.__itunes_title: + title = xml_elem('{%s}title' % ITUNES_NS, entry) + title.text = self.__itunes_title return entry def itunes_author(self, itunes_author=None): @@ -272,3 +277,17 @@ def itunes_episode(self, itunes_episode=None): if itunes_episode is not None: self.__itunes_episode = int(itunes_episode) return self.__itunes_episode + + def itunes_title(self, itunes_title=None): + '''Get or set the itunes:title value for the podcast episode. + + An episode title specific for Apple Podcasts. Don’t specify the episode + number or season number in this tag. Also, don’t repeat the title of + your show within your episode title. + + :param itunes_title: Episode title specific for Apple Podcasts + :returns: Title specific for Apple Podcast + ''' + if itunes_title is not None: + self.__itunes_title = itunes_title + return self.__itunes_title diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index f81f773..4b7770b 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -82,6 +82,7 @@ def test_podcastEntryItems(self): fe.podcast.itunes_summary('x') fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) + fe.podcast.itunes_title('Podcast Title') assert fe.podcast.itunes_author() == 'Lars Kiesow' assert fe.podcast.itunes_block() == 'x' assert fe.podcast.itunes_duration() == '00:01:30' @@ -93,6 +94,7 @@ def test_podcastEntryItems(self): assert fe.podcast.itunes_summary() == 'x' assert fe.podcast.itunes_season() == 1 assert fe.podcast.itunes_episode() == 1 + assert fe.podcast.itunes_title() == 'Podcast Title' # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} From 493bfd93b67255ce70829cdea39d999dac5e49f5 Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Tue, 21 Apr 2020 11:57:18 +0200 Subject: [PATCH 32/47] Additional check for itunes_type --- feedgen/ext/podcast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index d070944..c455a10 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -97,7 +97,7 @@ def extend_rss(self, rss_feed): summary = xml_elem('{%s}summary' % ITUNES_NS, channel) summary.text = self.__itunes_summary - if self.__itunes_type: + if self.__itunes_type in ('episodic', 'serial'): type = xml_elem('{%s}type' % ITUNES_NS, channel) type.text = self.__itunes_type From 2777ee939c6f224d7a10bddff2d3354275f2acdd Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Tue, 21 Apr 2020 12:19:01 +0200 Subject: [PATCH 33/47] Add episodeType tag --- feedgen/__main__.py | 1 + feedgen/ext/podcast_entry.py | 28 +++++++++++++++++++++++++++ tests/test_extensions/test_podcast.py | 2 ++ 3 files changed, 31 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 77da67b..8388dbc 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -105,6 +105,7 @@ def main(): fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) fe.podcast.itunes_title('First podcast episode') + fe.podcast.itunes_episode_type('full') print_enc(fg.rss_str(pretty=True)) elif arg == 'torrent': diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 2d201b5..2d60c2d 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -33,6 +33,7 @@ def __init__(self): self.__itunes_season = None self.__itunes_episode = None self.__itunes_title = None + self.__itunes_episode_type = None def extend_rss(self, entry): '''Add additional fields to an RSS item. @@ -92,6 +93,10 @@ def extend_rss(self, entry): if self.__itunes_title: title = xml_elem('{%s}title' % ITUNES_NS, entry) title.text = self.__itunes_title + + if self.__itunes_episode_type in ('full', 'trailer', 'bonus'): + episode_type = xml_elem('{%s}episodeType' % ITUNES_NS, entry) + episode_type.text = self.__itunes_episode_type return entry def itunes_author(self, itunes_author=None): @@ -291,3 +296,26 @@ def itunes_title(self, itunes_title=None): if itunes_title is not None: self.__itunes_title = itunes_title return self.__itunes_title + + def itunes_episode_type(self, itunes_episode_type=None): + '''Get or set the itunes:episodeType value of the item. This tag should + be used to indicate the episode type. + The three values for this tag are "full", "trailer" and "bonus". + + If an episode is a trailer or bonus content, use this tag. + + Specify full when you are submitting the complete content of your show. + Specify trailer when you are submitting a short, promotional piece of + content that represents a preview of your current show. + Specify bonus when you are submitting extra content for your show (for + example, behind the scenes information or interviews with the cast) or + cross-promotional content for another show. + + :param itunes_episode_type: The episode type + :returns: type of the episode. + ''' + if itunes_episode_type is not None: + if itunes_episode_type not in ('full', 'trailer', 'bonus'): + raise ValueError('Invalid value for episodeType tag') + self.__itunes_episode_type = itunes_episode_type + return self.__itunes_episode_type diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index 4b7770b..cb7085e 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -83,6 +83,7 @@ def test_podcastEntryItems(self): fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) fe.podcast.itunes_title('Podcast Title') + fe.podcast.itunes_episode_type('full') assert fe.podcast.itunes_author() == 'Lars Kiesow' assert fe.podcast.itunes_block() == 'x' assert fe.podcast.itunes_duration() == '00:01:30' @@ -95,6 +96,7 @@ def test_podcastEntryItems(self): assert fe.podcast.itunes_season() == 1 assert fe.podcast.itunes_episode() == 1 assert fe.podcast.itunes_title() == 'Podcast Title' + assert fe.podcast.itunes_episode_type() == 'full' # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} From af74c3bbf11d8e23f3a284c02f2b718ec7e02519 Mon Sep 17 00:00:00 2001 From: Chris Ring Date: Sat, 19 Sep 2020 13:52:29 -0700 Subject: [PATCH 34/47] Fix a few comment typos Fixes a few typos in comments and docs. Signed-off-by: Chris Ring --- feedgen/feed.py | 4 ++-- feedgen/util.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 9ebd219..ff28b61 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -761,7 +761,7 @@ def image(self, url=None, title=None, link=None, width=None, height=None, :param title: Describes the image. The default value is the feeds title. :param link: URL of the site the image will link to. The default is to - use the feeds first altertate link. + use the feeds first alternate link. :param width: Width of the image in pixel. The maximum is 144. :param height: The height of the image. The maximum is 400. :param description: Title of the link. @@ -802,7 +802,7 @@ def copyright(self, copyright=None): return self.rights(copyright) def subtitle(self, subtitle=None): - '''Get or set the subtitle value of the cannel which contains a + '''Get or set the subtitle value of the channel which contains a human-readable description or subtitle for the feed. This ATOM property will also set the value for rss:description. diff --git a/feedgen/util.py b/feedgen/util.py index 8b4e6e5..5a97887 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -53,7 +53,7 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): allowed_values = {} if defaults is None: defaults = {} - # Make shure that we have a list of dicts. Even if there is only one. + # Make sure that we have a list of dicts. Even if there is only one. if not isinstance(val, list): val = [val] for elem in val: From 44531536cff8c0e8722c82895c670792333ac08c Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Tue, 16 Nov 2021 06:31:31 +1100 Subject: [PATCH 35/47] docs: Fix a few typos There are small typos in: - feedgen/ext/dc.py - feedgen/ext/geo_entry.py - feedgen/feed.py - feedgen/util.py Fixes: - Should read `default` rather than `deault`. - Should read `already` rather than `alredy`. - Should read `omitted` rather than `omittet`. - Should read `sure` rather than `shure`. - Should read `rights` rather than `rightss`. - Should read `generated` rather than `generaated`. --- feedgen/ext/dc.py | 28 ++++++++++++++-------------- feedgen/ext/geo_entry.py | 2 +- feedgen/feed.py | 4 ++-- feedgen/util.py | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index f731c0b..466c66d 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -91,7 +91,7 @@ def dc_contributor(self, contributor=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-contributor :param contributor: Contributor or list of contributors. - :param replace: Replace alredy set contributors (deault: False). + :param replace: Replace already set contributors (default: False). :returns: List of contributors. ''' if contributor is not None: @@ -139,7 +139,7 @@ def dc_creator(self, creator=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-creator :param creator: Creator or list of creators. - :param replace: Replace alredy set creators (deault: False). + :param replace: Replace already set creators (default: False). :returns: List of creators. ''' if creator is not None: @@ -158,7 +158,7 @@ def dc_date(self, date=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-date :param date: Date or list of dates. - :param replace: Replace alredy set dates (deault: True). + :param replace: Replace already set dates (default: True). :returns: List of dates. ''' if date is not None: @@ -176,7 +176,7 @@ def dc_description(self, description=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-description :param description: Description or list of descriptions. - :param replace: Replace alredy set descriptions (deault: True). + :param replace: Replace already set descriptions (default: True). :returns: List of descriptions. ''' if description is not None: @@ -195,7 +195,7 @@ def dc_format(self, format=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-format :param format: Format of the resource or list of formats. - :param replace: Replace alredy set format (deault: True). + :param replace: Replace already set format (default: True). :returns: Format of the resource. ''' if format is not None: @@ -214,7 +214,7 @@ def dc_identifier(self, identifier=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-identifier :param identifier: Identifier of the resource or list of identifiers. - :param replace: Replace alredy set identifier (deault: True). + :param replace: Replace already set identifier (default: True). :returns: Identifiers of the resource. ''' if identifier is not None: @@ -233,7 +233,7 @@ def dc_language(self, language=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-language :param language: Language or list of languages. - :param replace: Replace alredy set languages (deault: True). + :param replace: Replace already set languages (default: True). :returns: List of languages. ''' if language is not None: @@ -252,7 +252,7 @@ def dc_publisher(self, publisher=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-publisher :param publisher: Publisher or list of publishers. - :param replace: Replace alredy set publishers (deault: False). + :param replace: Replace already set publishers (default: False). :returns: List of publishers. ''' if publisher is not None: @@ -270,7 +270,7 @@ def dc_relation(self, relation=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-relation :param relation: Relation or list of relations. - :param replace: Replace alredy set relations (deault: False). + :param replace: Replace already set relations (default: False). :returns: List of relations. ''' if relation is not None: @@ -289,7 +289,7 @@ def dc_rights(self, rights=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-rights :param rights: Rights information or list of rights information. - :param replace: Replace alredy set rightss (deault: False). + :param replace: Replace already set rights (default: False). :returns: List of rights information. ''' if rights is not None: @@ -313,7 +313,7 @@ def dc_source(self, source=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-source :param source: Source or list of sources. - :param replace: Replace alredy set sources (deault: False). + :param replace: Replace already set sources (default: False). :returns: List of sources. ''' if source is not None: @@ -331,7 +331,7 @@ def dc_subject(self, subject=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-subject :param subject: Subject or list of subjects. - :param replace: Replace alredy set subjects (deault: False). + :param replace: Replace already set subjects (default: False). :returns: List of subjects. ''' if subject is not None: @@ -349,7 +349,7 @@ def dc_title(self, title=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-title :param title: Title or list of titles. - :param replace: Replace alredy set titles (deault: False). + :param replace: Replace already set titles (default: False). :returns: List of titles. ''' if title is not None: @@ -368,7 +368,7 @@ def dc_type(self, type=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-type :param type: Type or list of types. - :param replace: Replace alredy set types (deault: False). + :param replace: Replace already set types (default: False). :returns: List of types. ''' if type is not None: diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index bb06cc2..4c18cfe 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -281,7 +281,7 @@ def geom_from_geo_interface(self, geom): - Point - LineString - Polygon (if there are holes / donuts in the polygons a warning will - be generaated + be generated Other GeoJson types will raise a ``ValueError``. diff --git a/feedgen/feed.py b/feedgen/feed.py index 9ebd219..630be02 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -997,7 +997,7 @@ def webMaster(self, webMaster=None): def add_entry(self, feedEntry=None, order='prepend'): '''This method will add a new entry to the feed. If the feedEntry - argument is omittet a new Entry object is created automatically. This + argument is omitted a new Entry object is created automatically. This is the preferred way to add new entries to a feed. :param feedEntry: FeedEntry object to add. @@ -1042,7 +1042,7 @@ def add_entry(self, feedEntry=None, order='prepend'): def add_item(self, item=None): '''This method will add a new item to the feed. If the item argument is - omittet a new FeedEntry object is created automatically. This is just + omitted a new FeedEntry object is created automatically. This is just another name for add_entry(...) ''' return self.add_entry(item) diff --git a/feedgen/util.py b/feedgen/util.py index 8b4e6e5..5a97887 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -53,7 +53,7 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): allowed_values = {} if defaults is None: defaults = {} - # Make shure that we have a list of dicts. Even if there is only one. + # Make sure that we have a list of dicts. Even if there is only one. if not isinstance(val, list): val = [val] for elem in val: From 1e9282d0c922f6aab649554d2939dcf68341503b Mon Sep 17 00:00:00 2001 From: Chenxing Luo Date: Mon, 2 Oct 2023 11:09:09 -0400 Subject: [PATCH 36/47] Fix etree to string conversion in FeedGenerator When converting `feed` object to a string, using `atom_str` or `rss_str` methods, with `etree.tostring` method, `feed` instead of `doc` is supplied. In this case, changes applied outside the was lost (e.g., adding a processing instruction `` as sibling to ). The `feed` object has now been replaced by the `doc` object in both the `_create_atom` and `_create_rss` methods, ensuring the correct conversion and preventing possible lost of information in the feed generation. --- feedgen/feed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 9ebd219..fa82702 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -220,7 +220,7 @@ def atom_str(self, pretty=False, extensions=True, encoding='UTF-8', `_ ''' feed, doc = self._create_atom(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, + return etree.tostring(doc, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) def atom_file(self, filename, extensions=True, pretty=False, @@ -396,7 +396,7 @@ def rss_str(self, pretty=False, extensions=True, encoding='UTF-8', `_ ''' feed, doc = self._create_rss(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, + return etree.tostring(doc, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) def rss_file(self, filename, extensions=True, pretty=False, From 966fea46ba4954f5511266e630697c4f06fb1221 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 21 Dec 2023 21:29:04 +0100 Subject: [PATCH 37/47] Fix flake8 complaints This pull request fixes a number of complaints the current version of flake8 has about the code. --- feedgen/__main__.py | 10 ++++----- feedgen/entry.py | 50 +++++++++++++++++++++--------------------- feedgen/ext/base.py | 3 ++- feedgen/ext/podcast.py | 4 ++-- feedgen/feed.py | 24 ++++++++++---------- feedgen/util.py | 6 ++--- tests/test_main.py | 4 ++-- 7 files changed, 51 insertions(+), 50 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index abc0737..9f008b3 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -36,8 +36,8 @@ def print_enc(s): - '''Print function compatible with both python2 and python3 accepting strings - and byte arrays. + '''Print function compatible with both python2 and python3 accepting + strings and byte arrays. ''' if sys.version_info[0] >= 3: print(s.decode('utf-8') if isinstance(s, bytes) else s) @@ -73,9 +73,9 @@ def main(): fe = fg.add_entry() fe.id('http://lernfunk.de/_MEDIAID_123#1') fe.title('First Element') - fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen - aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si - ista mala sunt, placet. Aut etiam, ut vestitum, sic sententiam + fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Tamen aberramus a proposito, et, ne longius, prorsus, inquam, Piso, + si ista mala sunt, placet. Aut etiam, ut vestitum, sic sententiam habeas aliam domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, verba.''') diff --git a/feedgen/entry.py b/feedgen/entry.py index 66400ba..71d295a 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -58,8 +58,8 @@ def _add_text_elm(entry, data, name): class FeedEntry(object): - '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item - node. + '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds + item node. ''' def __init__(self): @@ -115,8 +115,8 @@ def atom_entry(self, extensions=True): # element. if not self.__atom_content: links = self.__atom_link or [] - if not [l for l in links if l.get('rel') == 'alternate']: - raise ValueError('Entry must contain an alternate link or ' + + if not [link for link in links if link.get('rel') == 'alternate']: + raise ValueError('Entry must contain an alternate link or ' 'a content element.') # Add author elements @@ -136,18 +136,18 @@ def atom_entry(self, extensions=True): _add_text_elm(entry, self.__atom_content, 'content') - for l in self.__atom_link or []: - link = xml_elem('link', entry, href=l['href']) - if l.get('rel'): - link.attrib['rel'] = l['rel'] - if l.get('type'): - link.attrib['type'] = l['type'] - if l.get('hreflang'): - link.attrib['hreflang'] = l['hreflang'] - if l.get('title'): - link.attrib['title'] = l['title'] - if l.get('length'): - link.attrib['length'] = l['length'] + for link in self.__atom_link or []: + link = xml_elem('link', entry, href=link['href']) + if link.get('rel'): + link.attrib['rel'] = link['rel'] + if link.get('type'): + link.attrib['type'] = link['type'] + if link.get('hreflang'): + link.attrib['hreflang'] = link['hreflang'] + if link.get('title'): + link.attrib['title'] = link['title'] + if link.get('length'): + link.attrib['length'] = link['length'] _add_text_elm(entry, self.__atom_summary, 'summary') @@ -449,13 +449,13 @@ def link(self, link=None, replace=False, **kwargs): {'rel': ['alternate', 'enclosure', 'related', 'self', 'via']}, {'rel': 'alternate'}) # RSS only needs one URL. We use the first link for RSS: - for l in self.__atom_link: - if l.get('rel') == 'alternate': - self.__rss_link = l['href'] - elif l.get('rel') == 'enclosure': - self.__rss_enclosure = {'url': l['href']} - self.__rss_enclosure['type'] = l.get('type') - self.__rss_enclosure['length'] = l.get('length') or '0' + for link in self.__atom_link: + if link.get('rel') == 'alternate': + self.__rss_link = link['href'] + elif link.get('rel') == 'enclosure': + self.__rss_enclosure = {'url': link['href']} + self.__rss_enclosure['type'] = link.get('type') + self.__rss_enclosure['length'] = link.get('length') or '0' # return the set with more information (atom) return self.__atom_link @@ -574,8 +574,8 @@ def contributor(self, contributor=None, replace=False, **kwargs): return self.__atom_contributor def published(self, published=None): - '''Set or get the published value which contains the time of the initial - creation or first availability of the entry. + '''Set or get the published value which contains the time of the + initial creation or first availability of the entry. The value can either be a string which will automatically be parsed or a datetime.datetime object. In any case it is necessary that the value diff --git a/feedgen/ext/base.py b/feedgen/ext/base.py index 521139e..b94826d 100644 --- a/feedgen/ext/base.py +++ b/feedgen/ext/base.py @@ -21,7 +21,8 @@ def extend_ns(self): return dict() def extend_rss(self, feed): - '''Extend a RSS feed xml structure containing all previously set fields. + '''Extend a RSS feed xml structure containing all previously set + fields. :param feed: The feed xml root element. :returns: The feed root element. diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 4c7eb0b..e0d7d05 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -292,8 +292,8 @@ def itunes_owner(self, name=None, email=None): return self.__itunes_owner def itunes_subtitle(self, itunes_subtitle=None): - '''Get or set the itunes:subtitle value for the podcast. The contents of - this tag are shown in the Description column in iTunes. The subtitle + '''Get or set the itunes:subtitle value for the podcast. The contents + of this tag are shown in the Description column in iTunes. The subtitle displays best if it is only a few words long. :param itunes_subtitle: Subtitle of the podcast. diff --git a/feedgen/feed.py b/feedgen/feed.py index 9ebd219..33e0c3f 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -130,18 +130,18 @@ def _create_atom(self, extensions=True): uri = xml_elem('uri', author) uri.text = a.get('uri') - for l in self.__atom_link or []: - link = xml_elem('link', feed, href=l['href']) - if l.get('rel'): - link.attrib['rel'] = l['rel'] - if l.get('type'): - link.attrib['type'] = l['type'] - if l.get('hreflang'): - link.attrib['hreflang'] = l['hreflang'] - if l.get('title'): - link.attrib['title'] = l['title'] - if l.get('length'): - link.attrib['length'] = l['length'] + for ln in self.__atom_link or []: + link = xml_elem('link', feed, href=ln['href']) + if ln.get('rel'): + link.attrib['rel'] = ln['rel'] + if ln.get('type'): + link.attrib['type'] = ln['type'] + if ln.get('hreflang'): + link.attrib['hreflang'] = ln['hreflang'] + if ln.get('title'): + link.attrib['title'] = ln['title'] + if ln.get('length'): + link.attrib['length'] = ln['length'] for c in self.__atom_category or []: cat = xml_elem('category', feed, term=c['term']) diff --git a/feedgen/util.py b/feedgen/util.py index 8b4e6e5..e8125e8 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -35,9 +35,9 @@ def xml_elem(name, parent=None, **kwargs): def ensure_format(val, allowed, required, allowed_values=None, defaults=None): - '''Takes a dictionary or a list of dictionaries and check if all keys are in - the set of allowed keys, if all required keys are present and if the values - of a specific key are ok. + '''Takes a dictionary or a list of dictionaries and check if all keys are + in the set of allowed keys, if all required keys are present and if the + values of a specific key are ok. :param val: Dictionaries to check. :param allowed: Set of allowed keys. diff --git a/tests/test_main.py b/tests/test_main.py index efea777..af0b19e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,8 +22,8 @@ def test_usage(self): assert e.code is None def test_feed(self): - for ftype in 'rss', 'atom', 'podcast', 'torrent', 'dc.rss', 'dc.atom',\ - 'syndication.rss', 'syndication.atom': + for ftype in 'rss', 'atom', 'podcast', 'torrent', 'dc.rss', \ + 'dc.atom', 'syndication.rss', 'syndication.atom': sys.argv = ['feedgen', ftype] try: __main__.main() From 8eef40385da97453b5a3bc6750158b79b5446f3c Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 21 Dec 2023 21:38:17 +0100 Subject: [PATCH 38/47] Update dependency versions This patch updates the dependency version set in `requirements.txt` to allow using newer dependency versions. --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3c01ee4..631c4c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -lxml==4.2.5 -python_dateutil==2.8.0 +lxml>=4.2.5 +python_dateutil>=2.8.0 From c1c2859a7c8bfde66652c18ca5ec7e701436883e Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 21 Dec 2023 20:46:57 +0100 Subject: [PATCH 39/47] Switch to GitHub Actions This patch switches from Travis CI to GitHub Actions for running CI tests on pull requests and pushes. --- .github/workflows/tests.yml | 46 +++++++++++++++++++++++++++++++++++++ .travis.yml | 25 -------------------- readme.rst | 9 -------- 3 files changed, 46 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c573c55 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + - push + - pull_request + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: set up python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: install system dependencies + run: | + sudo apt update + sudo apt install python3-lxml python3-dateutil + + - name: install Python dependencies + run: | + pip install bandit flake8 coverage liccheck + + - name: install feedgen + run: | + python setup.py install + + - name: run linter + run: make test + + - name: run license check + run: liccheck -s .licenses.ini + + - name: run tests + run: | + python -m feedgen + python -m feedgen atom + python -m feedgen rss + + + - name: run coverage + run: coverage report --fail-under=93 + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c58695e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: python - -dist: bionic - -# https://devguide.python.org/#branchstatus -python: - - 3.6 - - 3.7 - - 3.8 - -install: - - pip install bandit flake8 python-coveralls coverage liccheck - - pip install -r requirements.txt - - python setup.py bdist_wheel - - pip install dist/feedgen* - -script: - - make test - - liccheck -s .licenses.ini - - python -m feedgen - - python -m feedgen atom - - python -m feedgen rss - -after_success: - - coveralls diff --git a/readme.rst b/readme.rst index 3aa2c8f..3edeaff 100644 --- a/readme.rst +++ b/readme.rst @@ -2,15 +2,6 @@ Feedgenerator ============= -.. image:: https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master - :target: https://travis-ci.org/lkiesow/python-feedgen - :alt: Build Status - -.. image:: https://coveralls.io/repos/github/lkiesow/python-feedgen/badge.svg?branch=master - :target: https://coveralls.io/github/lkiesow/python-feedgen?branch=master - :alt: Test Coverage Status - - This module can be used to generate web feeds in both ATOM and RSS format. It has support for extensions. Included is for example an extension to produce Podcasts. From ef236ae4909bfd8f8254b36b2a68e2455623345f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 21 Dec 2023 23:10:50 +0100 Subject: [PATCH 40/47] Improve module documentation This patch updates the modules description in `__init__.py` which was a bit weird. This closes #102 --- feedgen/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 624658e..b28c0e9 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -62,17 +62,15 @@ To add entries (items) to a feed you need to create new FeedEntry objects and append them to the list of entries in the FeedGenerator. The most - convenient way to go is to use the FeedGenerator itself for the - instantiation of the FeedEntry object:: + convenient way to do this, is to call the method add_entry(...) like this:: >>> fe = fg.add_entry() >>> fe.id('http://lernfunk.de/media/654321/1') >>> fe.title('The First Episode') - The FeedGenerators method add_entry(...) without argument provides will - automatically generate a new FeedEntry object, append it to the feeds - internal list of entries and return it, so that additional data can be - added. + The FeedGenerator's method add_entry(...) will automatically create a new + FeedEntry object, append it to the feeds internal list of entries and + return it, so that additional data can be added. ---------- Extensions From 2bc43d3b2616324fe5e225b58c49fa925e2fecc1 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Tue, 28 Jan 2020 23:07:13 +0100 Subject: [PATCH 41/47] Use Unittest Asserts This patch switches to the assert statements provided by Python's unit test framework to assure the statements are always executed and produce proper error messages in case of test failures. --- tests/test_entry.py | 75 ++++---- tests/test_extensions/test_dc.py | 6 +- tests/test_extensions/test_geo.py | 5 +- tests/test_extensions/test_media.py | 16 +- tests/test_extensions/test_podcast.py | 54 +++--- tests/test_extensions/test_syndication.py | 6 +- tests/test_extensions/test_torrent.py | 14 +- tests/test_feed.py | 225 ++++++++++++++-------- tests/test_main.py | 17 +- 9 files changed, 238 insertions(+), 180 deletions(-) diff --git a/tests/test_entry.py b/tests/test_entry.py index 5eaeffa..adfd8b5 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -44,8 +44,8 @@ def setUp(self): def test_setEntries(self): fg2 = FeedGenerator() fg2.entry(self.fg.entry()) - assert len(fg2.entry()) == 3 - assert self.fg.entry() == fg2.entry() + self.assertEqual(len(fg2.entry()), 3) + self.assertEqual(self.fg.entry(), fg2.entry()) def test_loadExtension(self): fe = self.fg.add_item() @@ -53,64 +53,64 @@ def test_loadExtension(self): fe.title(u'…') fe.content(u'…') fe.load_extension('base') - assert fe.base - assert self.fg.atom_str() + self.assertTrue(fe.base) + self.assertTrue(self.fg.atom_str()) def test_checkEntryNumbers(self): fg = self.fg - assert len(fg.entry()) == 3 + self.assertEqual(len(fg.entry()), 3) def test_TestEntryItems(self): fe = self.fg.add_item() fe.title('qwe') - assert fe.title() == 'qwe' + self.assertEqual(fe.title(), 'qwe') author = fe.author(email='ldoe@example.com')[0] - assert not author.get('name') - assert author.get('email') == 'ldoe@example.com' + self.assertFalse(author.get('name')) + self.assertEqual(author.get('email'), 'ldoe@example.com') author = fe.author(name='John Doe', email='jdoe@example.com', replace=True)[0] - assert author.get('name') == 'John Doe' - assert author.get('email') == 'jdoe@example.com' + self.assertEqual(author.get('name'), 'John Doe') + self.assertEqual(author.get('email'), 'jdoe@example.com') contributor = fe.contributor(name='John Doe', email='jdoe@ex.com')[0] - assert contributor == fe.contributor()[0] - assert contributor.get('name') == 'John Doe' - assert contributor.get('email') == 'jdoe@ex.com' + self.assertEqual(contributor, fe.contributor()[0]) + self.assertEqual(contributor.get('name'), 'John Doe') + self.assertEqual(contributor.get('email'), 'jdoe@ex.com') link = fe.link(href='http://lkiesow.de', rel='alternate')[0] - assert link == fe.link()[0] - assert link.get('href') == 'http://lkiesow.de' - assert link.get('rel') == 'alternate' + self.assertEqual(link, fe.link()[0]) + self.assertEqual(link.get('href'), 'http://lkiesow.de') + self.assertEqual(link.get('rel'), 'alternate') fe.guid('123') - assert fe.guid().get('guid') == '123' + self.assertEqual(fe.guid().get('guid'), '123') fe.updated('2017-02-05 13:26:58+01:00') - assert fe.updated().year == 2017 + self.assertEqual(fe.updated().year, 2017) fe.summary('asdf') - assert fe.summary() == {'summary': 'asdf'} + self.assertEqual(fe.summary(), {'summary': 'asdf'}) fe.description('asdfx') - assert fe.description() == 'asdfx' + self.assertEqual(fe.description(), 'asdfx') fe.pubDate('2017-02-05 13:26:58+01:00') - assert fe.pubDate().year == 2017 + self.assertEqual(fe.pubDate().year, 2017) fe.rights('asdfx') - assert fe.rights() == 'asdfx' + self.assertEqual(fe.rights(), 'asdfx') source = fe.source(url='https://example.com', title='Test') - assert source.get('title') == 'Test' - assert source.get('url') == 'https://example.com' + self.assertEqual(source.get('title'), 'Test') + self.assertEqual(source.get('url'), 'https://example.com') fe.comments('asdfx') - assert fe.comments() == 'asdfx' + self.assertEqual(fe.comments(), 'asdfx') fe.enclosure(url='http://lkiesow.de', type='text/plain', length='1') - assert fe.enclosure().get('url') == 'http://lkiesow.de' + self.assertEqual(fe.enclosure().get('url'), 'http://lkiesow.de') fe.ttl(8) - assert fe.ttl() == 8 + self.assertEqual(fe.ttl(), 8) self.fg.rss_str() self.fg.atom_str() def test_checkItemNumbers(self): fg = self.fg - assert len(fg.item()) == 3 + self.assertEqual(len(fg.item()), 3) def test_checkEntryContent(self): fg = self.fg - assert fg.entry() + self.assertTrue(fg.entry()) def test_removeEntryByIndex(self): fg = FeedGenerator() @@ -120,9 +120,9 @@ def test_removeEntryByIndex(self): fe = fg.add_entry() fe.id('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') - assert len(fg.entry()) == 1 + self.assertEqual(len(fg.entry()), 1) fg.remove_entry(0) - assert len(fg.entry()) == 0 + self.assertEqual(len(fg.entry()), 0) def test_removeEntryByEntry(self): fg = FeedGenerator() @@ -133,9 +133,9 @@ def test_removeEntryByEntry(self): fe.id('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') - assert len(fg.entry()) == 1 + self.assertEqual(len(fg.entry()), 1) fg.remove_entry(fe) - assert len(fg.entry()) == 0 + self.assertEqual(len(fg.entry()), 0) def test_categoryHasDomain(self): fg = FeedGenerator() @@ -147,12 +147,12 @@ def test_categoryHasDomain(self): fe.title('some title') fe.category([ {'term': 'category', - 'scheme': 'http://www.somedomain.com/category', + 'scheme': 'http://somedomain.com/category', 'label': 'Category', }]) result = fg.rss_str() - assert b'domain="http://www.somedomain.com/category"' in result + self.assertIn(b'domain="http://somedomain.com/category"', result) def test_content_cdata_type(self): fg = FeedGenerator() @@ -163,7 +163,8 @@ def test_content_cdata_type(self): fe.title('some title') fe.content('content', type='CDATA') result = fg.atom_str() - assert b'' in result + expected = b'' + self.assertIn(expected, result) def test_summary_html_type(self): fg = FeedGenerator() @@ -176,4 +177,4 @@ def test_summary_html_type(self): fe.summary('

summary

', type='html') result = fg.atom_str() expected = b'<p>summary</p>' - assert expected in result + self.assertIn(expected, result) diff --git a/tests/test_extensions/test_dc.py b/tests/test_extensions/test_dc.py index 1623804..af7dd94 100644 --- a/tests/test_extensions/test_dc.py +++ b/tests/test_extensions/test_dc.py @@ -24,8 +24,8 @@ def test_elements(self): if method.startswith('dc_'): m = getattr(self.fg.dc, method) m(method) - assert m() == [method] + self.assertEqual(m(), [method]) self.fg.id('123') - assert self.fg.atom_str() - assert self.fg.rss_str() + self.assertTrue(self.fg.atom_str()) + self.assertTrue(self.fg.rss_str()) diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 6dd401b..5de1f80 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -403,8 +403,9 @@ def test_geom_from_geointerface_warn_poly_interior(self): # Trigger a warning. fe.geo.geom_from_geo_interface(self.polygon_with_interior) # Verify some things - assert len(w) == 1 - assert issubclass(w[-1].category, GeoRSSPolygonInteriorWarning) + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, + GeoRSSPolygonInteriorWarning)) self.assertEqual(fe.geo.polygon(), str(self.polygon_with_interior)) diff --git a/tests/test_extensions/test_media.py b/tests/test_extensions/test_media.py index 7fd9e40..1f5a349 100644 --- a/tests/test_extensions/test_media.py +++ b/tests/test_extensions/test_media.py @@ -32,21 +32,21 @@ def test_media_content(self): root = etree.fromstring(self.fg.rss_str()) url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] + self.assertEqual(url, ['file1.xy', 'file1.xy']) # There is one without a group url = root.xpath('/rss/channel/item/media:content[1]/@url', namespaces=ns) - assert url == ['file.xy'] + self.assertEqual(url, ['file.xy']) # Check that we have the item in the resulting Atom feed root = etree.fromstring(self.fg.atom_str()) url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] + self.assertEqual(url, ['file1.xy', 'file1.xy']) fe.media.content(content=[], replace=True) - assert fe.media.content() == [] + self.assertEqual(fe.media.content(), []) def test_media_thumbnail(self): fe = self.fg.add_item() @@ -66,18 +66,18 @@ def test_media_thumbnail(self): url = root.xpath( '/rss/channel/item/media:group/media:thumbnail[1]/@url', namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] + self.assertEqual(url, ['file1.xy', 'file1.xy']) # There is one without a group url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url', namespaces=ns) - assert url == ['file.xy'] + self.assertEqual(url, ['file.xy']) # Check that we have the item in the resulting Atom feed root = etree.fromstring(self.fg.atom_str()) url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url', namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] + self.assertEqual(url, ['file1.xy', 'file1.xy']) fe.media.thumbnail(thumbnail=[], replace=True) - assert fe.media.thumbnail() == [] + self.assertEqual(fe.media.thumbnail(), []) diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index cb7085e..84c8c96 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -26,8 +26,8 @@ def test_category_new(self): cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', namespaces=ns) - assert cat[0] == 'Technology' - assert scat[0] == 'Podcasting' + self.assertEqual(cat[0], 'Technology') + self.assertEqual(scat[0], 'Podcasting') def test_category(self): self.fg.podcast.itunes_category('Technology', 'Podcasting') @@ -40,8 +40,8 @@ def test_category(self): cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', namespaces=ns) - assert cat[0] == 'Technology' - assert scat[0] == 'Podcasting' + self.assertEqual(cat[0], 'Technology') + self.assertEqual(scat[0], 'Podcasting') def test_podcastItems(self): fg = self.fg @@ -53,20 +53,20 @@ def test_podcastItems(self): fg.podcast.itunes_subtitle('x') fg.podcast.itunes_summary('x') fg.podcast.itunes_type('episodic') - assert fg.podcast.itunes_author() == 'Lars Kiesow' - assert fg.podcast.itunes_block() == 'x' - assert fg.podcast.itunes_complete() == 'no' - assert fg.podcast.itunes_explicit() == 'no' - assert fg.podcast.itunes_image() == 'x.png' - assert fg.podcast.itunes_subtitle() == 'x' - assert fg.podcast.itunes_summary() == 'x' - assert fg.podcast.itunes_type() == 'episodic' + self.assertEqual(fg.podcast.itunes_author(), 'Lars Kiesow') + self.assertEqual(fg.podcast.itunes_block(), 'x') + self.assertEqual(fg.podcast.itunes_complete(), 'no') + self.assertEqual(fg.podcast.itunes_explicit(), 'no') + self.assertEqual(fg.podcast.itunes_image(), 'x.png') + self.assertEqual(fg.podcast.itunes_subtitle(), 'x') + self.assertEqual(fg.podcast.itunes_summary(), 'x') + self.assertEqual(fg.podcast.itunes_type(), 'episodic') # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} root = etree.fromstring(self.fg.rss_str()) author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns) - assert author == ['Lars Kiesow'] + self.assertEqual(author, ['Lars Kiesow']) def test_podcastEntryItems(self): fe = self.fg.add_item() @@ -84,23 +84,23 @@ def test_podcastEntryItems(self): fe.podcast.itunes_episode(1) fe.podcast.itunes_title('Podcast Title') fe.podcast.itunes_episode_type('full') - assert fe.podcast.itunes_author() == 'Lars Kiesow' - assert fe.podcast.itunes_block() == 'x' - assert fe.podcast.itunes_duration() == '00:01:30' - assert fe.podcast.itunes_explicit() == 'no' - assert fe.podcast.itunes_image() == 'x.png' - assert fe.podcast.itunes_is_closed_captioned() - assert fe.podcast.itunes_order() == 1 - assert fe.podcast.itunes_subtitle() == 'x' - assert fe.podcast.itunes_summary() == 'x' - assert fe.podcast.itunes_season() == 1 - assert fe.podcast.itunes_episode() == 1 - assert fe.podcast.itunes_title() == 'Podcast Title' - assert fe.podcast.itunes_episode_type() == 'full' + self.assertEqual(fe.podcast.itunes_author(), 'Lars Kiesow') + self.assertEqual(fe.podcast.itunes_block(), 'x') + self.assertEqual(fe.podcast.itunes_duration(), '00:01:30') + self.assertEqual(fe.podcast.itunes_explicit(), 'no') + self.assertEqual(fe.podcast.itunes_image(), 'x.png') + self.assertTrue(fe.podcast.itunes_is_closed_captioned()) + self.assertEqual(fe.podcast.itunes_order(), 1) + self.assertEqual(fe.podcast.itunes_subtitle(), 'x') + self.assertEqual(fe.podcast.itunes_summary(), 'x') + self.assertEqual(fe.podcast.itunes_season(), 1) + self.assertEqual(fe.podcast.itunes_episode(), 1) + self.assertEqual(fe.podcast.itunes_title(), 'Podcast Title') + self.assertEqual(fe.podcast.itunes_episode_type(), 'full') # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} root = etree.fromstring(self.fg.rss_str()) author = root.xpath('/rss/channel/item/itunes:author/text()', namespaces=ns) - assert author == ['Lars Kiesow'] + self.assertEqual(author, ['Lars Kiesow']) diff --git a/tests/test_extensions/test_syndication.py b/tests/test_extensions/test_syndication.py index 7a187d7..029e100 100644 --- a/tests/test_extensions/test_syndication.py +++ b/tests/test_extensions/test_syndication.py @@ -22,7 +22,7 @@ def test_update_period(self): root = etree.fromstring(self.fg.rss_str()) a = root.xpath('/rss/channel/sy:UpdatePeriod', namespaces=self.SYN_NS) - assert a[0].text == period_type + self.assertEqual(a[0].text, period_type) def test_update_frequency(self): for frequency in (1, 100, 2000, 100000): @@ -30,11 +30,11 @@ def test_update_frequency(self): root = etree.fromstring(self.fg.rss_str()) a = root.xpath('/rss/channel/sy:UpdateFrequency', namespaces=self.SYN_NS) - assert a[0].text == str(frequency) + self.assertEqual(a[0].text, str(frequency)) def test_update_base(self): base = '2000-01-01T12:00+00:00' self.fg.syndication.update_base(base) root = etree.fromstring(self.fg.rss_str()) a = root.xpath('/rss/channel/sy:UpdateBase', namespaces=self.SYN_NS) - assert a[0].text == base + self.assertEqual(a[0].text, base) diff --git a/tests/test_extensions/test_torrent.py b/tests/test_extensions/test_torrent.py index e996fde..230ec1a 100644 --- a/tests/test_extensions/test_torrent.py +++ b/tests/test_extensions/test_torrent.py @@ -23,16 +23,16 @@ def test_podcastEntryItems(self): fe.torrent.seeds('1') fe.torrent.peers('2') fe.torrent.verified('1') - assert fe.torrent.filename() == 'file.xy' - assert fe.torrent.infohash() == '123' - assert fe.torrent.contentlength() == '23' - assert fe.torrent.seeds() == '1' - assert fe.torrent.peers() == '2' - assert fe.torrent.verified() == '1' + self.assertEqual(fe.torrent.filename(), 'file.xy') + self.assertEqual(fe.torrent.infohash(), '123') + self.assertEqual(fe.torrent.contentlength(), '23') + self.assertEqual(fe.torrent.seeds(), '1') + self.assertEqual(fe.torrent.peers(), '2') + self.assertEqual(fe.torrent.verified(), '1') # Check that we have the item in the resulting XML ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} root = etree.fromstring(self.fg.rss_str()) filename = root.xpath('/rss/channel/item/torrent:filename/text()', namespaces=ns) - assert filename == ['file.xy'] + self.assertEqual(filename, ['file.xy']) diff --git a/tests/test_feed.py b/tests/test_feed.py index fcdbd4a..d09014d 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -117,22 +117,22 @@ def setUp(self): def test_baseFeed(self): fg = self.fg - assert fg.id() == self.feedId - assert fg.title() == self.title + self.assertEqual(fg.id(), self.feedId) + self.assertEqual(fg.title(), self.title) - assert fg.author()[0]['name'] == self.authorName - assert fg.author()[0]['email'] == self.authorMail + self.assertEqual(fg.author()[0]['name'], self.authorName) + self.assertEqual(fg.author()[0]['email'], self.authorMail) - assert fg.link()[0]['href'] == self.linkHref - assert fg.link()[0]['rel'] == self.linkRel + self.assertEqual(fg.link()[0]['href'], self.linkHref) + self.assertEqual(fg.link()[0]['rel'], self.linkRel) - assert fg.logo() == self.logo - assert fg.subtitle() == self.subtitle + self.assertEqual(fg.logo(), self.logo) + self.assertEqual(fg.subtitle(), self.subtitle) - assert fg.link()[1]['href'] == self.link2Href - assert fg.link()[1]['rel'] == self.link2Rel + self.assertEqual(fg.link()[1]['href'], self.link2Href) + self.assertEqual(fg.link()[1]['rel'], self.link2Rel) - assert fg.language() == self.language + self.assertEqual(fg.language(), self.language) def test_atomFeedFile(self): fg = self.fg @@ -178,10 +178,10 @@ def test_rel_values_for_atom(self): nsAtom = self.nsAtom feed_links = feed.findall("{%s}link" % nsAtom) idx = 0 - assert len(links) == len(feed_links) + self.assertEqual(len(links), len(feed_links)) while idx < len(values_for_rel): - assert feed_links[idx].get('href') == links[idx]['href'] - assert feed_links[idx].get('rel') == links[idx]['rel'] + self.assertEqual(feed_links[idx].get('href'), links[idx]['href']) + self.assertEqual(feed_links[idx].get('rel'), links[idx]['rel']) idx += 1 def test_rel_values_for_rss(self): @@ -212,49 +212,85 @@ def test_rel_values_for_rss(self): atom_links = channel.findall("{%s}link" % nsAtom) # rss feed only implements atom's 'self' link - assert len(atom_links) == 1 - assert atom_links[0].get('href') == '%s/%s' % (self.linkHref, 'self') - assert atom_links[0].get('rel') == 'self' + self.assertEqual(len(atom_links), 1) + self.assertEqual(atom_links[0].get('href'), + '%s/%s' % (self.linkHref, 'self')) + self.assertEqual(atom_links[0].get('rel'), 'self') rss_links = channel.findall('link') # RSS only needs one URL. We use the first link for RSS: - assert len(rss_links) == 1 - assert rss_links[0].text == '%s/%s' % \ - (self.linkHref, 'working-copy-of'.replace('-', '_')) + self.assertEqual(len(rss_links), 1) + self.assertEqual( + rss_links[0].text, + '%s/%s' % (self.linkHref, 'working-copy-of'.replace('-', '_'))) def checkAtomString(self, atomString): feed = etree.fromstring(atomString) - nsAtom = self.nsAtom - - assert feed.find("{%s}title" % nsAtom).text == self.title - assert feed.find("{%s}updated" % nsAtom).text is not None - assert feed.find("{%s}id" % nsAtom).text == self.feedId - assert feed.find("{%s}category" % nsAtom)\ - .get('term') == self.categoryTerm - assert feed.find("{%s}category" % nsAtom)\ - .get('label') == self.categoryLabel - assert feed.find("{%s}author" % nsAtom)\ - .find("{%s}name" % nsAtom).text == self.authorName - assert feed.find("{%s}author" % nsAtom)\ - .find("{%s}email" % nsAtom).text == self.authorMail - assert feed.findall("{%s}link" % nsAtom)[0]\ - .get('href') == self.linkHref - assert feed.findall("{%s}link" % nsAtom)[0].get('rel') == self.linkRel - assert feed.findall("{%s}link" % nsAtom)[1]\ - .get('href') == self.link2Href - assert feed.findall("{%s}link" % nsAtom)[1].get('rel') == self.link2Rel - assert feed.find("{%s}logo" % nsAtom).text == self.logo - assert feed.find("{%s}icon" % nsAtom).text == self.icon - assert feed.find("{%s}subtitle" % nsAtom).text == self.subtitle - assert feed.find("{%s}contributor" % nsAtom)\ - .find("{%s}name" % nsAtom).text == self.contributor['name'] - assert feed.find("{%s}contributor" % nsAtom)\ - .find("{%s}email" % nsAtom).text == self.contributor['email'] - assert feed.find("{%s}contributor" % nsAtom)\ - .find("{%s}uri" % nsAtom).text == self.contributor['uri'] - assert feed.find("{%s}rights" % nsAtom).text == self.copyright + nsAtom = "{" + self.nsAtom + "}" + + print(nsAtom) + print(f"{nsAtom}title") + testcases = [ + ( + feed.find(f"{nsAtom}title").text, + self.title + ), ( + feed.find(f"{nsAtom}id").text, + self.feedId + ), ( + feed.find(f"{nsAtom}category").get('term'), + self.categoryTerm + ), ( + feed.find(f"{nsAtom}category").get('label'), + self.categoryLabel + ), ( + feed.find(f"{nsAtom}author").find(f"{nsAtom}name").text, + self.authorName + ), ( + feed.find(f"{nsAtom}author").find(f"{nsAtom}email").text, + self.authorMail + ), ( + feed.findall(f"{nsAtom}link")[0].get('href'), + self.linkHref + ), ( + feed.findall(f"{nsAtom}link")[0].get('rel'), + self.linkRel + ), ( + feed.findall(f"{nsAtom}link")[1].get('href'), + self.link2Href + ), ( + feed.findall(f"{nsAtom}link")[1].get('rel'), + self.link2Rel + ), ( + feed.find(f"{nsAtom}logo").text, + self.logo + ), ( + feed.find(f"{nsAtom}icon").text, + self.icon + ), ( + feed.find(f"{nsAtom}subtitle").text, + self.subtitle + ), ( + feed.find(f"{nsAtom}contributor").find(f"{nsAtom}name").text, + self.contributor['name'] + ), ( + feed.find(f"{nsAtom}contributor").find(f"{nsAtom}email").text, + self.contributor['email'] + ), ( + feed.find(f"{nsAtom}contributor").find(f"{nsAtom}uri").text, + self.contributor['uri'] + ), ( + feed.find(f"{nsAtom}rights").text, + self.copyright + )] + for actual, expected in testcases: + self.assertEqual(actual, expected) + + self.assertIsNot( + feed.find(f"{nsAtom}updated").text, + None) def test_rssFeedFile(self): fg = self.fg @@ -301,39 +337,64 @@ def checkRssString(self, rssString): nsAtom = self.nsAtom ch = feed.find("channel") - assert ch is not None - - assert ch.find("title").text == self.title - assert ch.find("description").text == self.subtitle - assert ch.find("lastBuildDate").text is not None - docs = "http://www.rssboard.org/rss-specification" - assert ch.find("docs").text == docs - assert ch.find("generator").text == "python-feedgen" - assert ch.findall("{%s}link" % nsAtom)[0].get('href') == self.link2Href - assert ch.findall("{%s}link" % nsAtom)[0].get('rel') == self.link2Rel - assert ch.find("image").find("url").text == self.logo - assert ch.find("image").find("title").text == self.title - assert ch.find("image").find("link").text == self.link2Href - assert ch.find("category").text == self.categoryLabel - assert ch.find("cloud").get('domain') == self.cloudDomain - assert ch.find("cloud").get('port') == self.cloudPort - assert ch.find("cloud").get('path') == self.cloudPath - assert ch.find("cloud").get('registerProcedure') == \ - self.cloudRegisterProcedure - assert ch.find("cloud").get('protocol') == self.cloudProtocol - assert ch.find("copyright").text == self.copyright - assert ch.find("docs").text == self.docs - assert ch.find("managingEditor").text == self.managingEditor - assert ch.find("rating").text == self.rating - assert ch.find("skipDays").find("day").text == self.skipDays - assert int(ch.find("skipHours").find("hour").text) == self.skipHours - assert ch.find("textInput").get('title') == self.textInputTitle - assert ch.find("textInput").get('description') == \ - self.textInputDescription - assert ch.find("textInput").get('name') == self.textInputName - assert ch.find("textInput").get('link') == self.textInputLink - assert int(ch.find("ttl").text) == self.ttl - assert ch.find("webMaster").text == self.webMaster + self.assertIsNot(ch, None) + + self.assertEqual(ch.find("title").text, + self.title) + self.assertEqual(ch.find("description").text, + self.subtitle) + self.assertIsNot(ch.find("lastBuildDate").text, + None) + self.assertEqual(ch.find("docs").text, + "http://www.rssboard.org/rss-specification") + self.assertEqual(ch.find("generator").text, + "python-feedgen") + self.assertEqual(ch.findall("{%s}link" % nsAtom)[0].get('href'), + self.link2Href) + self.assertEqual(ch.findall("{%s}link" % nsAtom)[0].get('rel'), + self.link2Rel) + self.assertEqual(ch.find("image").find("url").text, + self.logo) + self.assertEqual(ch.find("image").find("title").text, + self.title) + self.assertEqual(ch.find("image").find("link").text, + self.link2Href) + self.assertEqual(ch.find("category").text, + self.categoryLabel) + self.assertEqual(ch.find("cloud").get('domain'), + self.cloudDomain) + self.assertEqual(ch.find("cloud").get('port'), + self.cloudPort) + self.assertEqual(ch.find("cloud").get('path'), + self.cloudPath) + self.assertEqual(ch.find("cloud").get('registerProcedure'), + self.cloudRegisterProcedure) + self.assertEqual(ch.find("cloud").get('protocol'), + self.cloudProtocol) + self.assertEqual(ch.find("copyright").text, + self.copyright) + self.assertEqual(ch.find("docs").text, + self.docs) + self.assertEqual(ch.find("managingEditor").text, + self.managingEditor) + self.assertEqual(ch.find("rating").text, + self.rating) + self.assertEqual(ch.find("skipDays").find("day").text, + self.skipDays) + self.assertEqual(int(ch.find("skipHours").find("hour").text), + self.skipHours) + self.assertEqual(ch.find("textInput").get('title'), + self.textInputTitle) + self.assertEqual(ch.find("textInput").get('description'), + self.textInputDescription) + self.assertEqual(ch.find("textInput").get('name'), + self.textInputName) + self.assertEqual(ch.find("textInput").get('link'), + self.textInputLink) + self.assertEqual(int(ch.find("ttl").text), + self.ttl) + self.assertEqual(ch.find("webMaster").text, + self.webMaster) if __name__ == '__main__': diff --git a/tests/test_main.py b/tests/test_main.py index af0b19e..af7e981 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -16,19 +16,15 @@ class TestSequenceFunctions(unittest.TestCase): def test_usage(self): sys.argv = ['feedgen'] - try: + with self.assertRaises(SystemExit) as e: __main__.main() - except BaseException as e: - assert e.code is None + self.assertEqual(e.exception.code, None) def test_feed(self): for ftype in 'rss', 'atom', 'podcast', 'torrent', 'dc.rss', \ 'dc.atom', 'syndication.rss', 'syndication.atom': sys.argv = ['feedgen', ftype] - try: - __main__.main() - except Exception: - assert False + __main__.main() def test_file(self): for extemsion in '.atom', '.rss': @@ -36,7 +32,6 @@ def test_file(self): sys.argv = ['feedgen', filename] try: __main__.main() - except Exception: - assert False - os.close(fh) - os.remove(filename) + finally: + os.close(fh) + os.remove(filename) From 51d743f5d9d3a21356ad4e7fe58075f86d934c28 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 22 Dec 2023 11:22:23 +0100 Subject: [PATCH 42/47] Allow integer to be used for enclosure length This patch allows integer to be used for specifying the enclosure length instead of just allowing string values. This is certainly the more natural choice for something representing a number. With this patch string values will continue to work. This fixes #104 --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 71d295a..f7a13a8 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -668,7 +668,7 @@ def enclosure(self, url=None, length=None, type=None): :returns: Data of the enclosure element. ''' if url is not None: - self.link(href=url, rel='enclosure', type=type, length=length) + self.link(href=url, rel='enclosure', type=type, length=str(length)) return self.__rss_enclosure def ttl(self, ttl=None): From 7110f3bbb7b42f6fa4b40b61c3d403e0ea031265 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 22 Dec 2023 23:12:53 +0100 Subject: [PATCH 43/47] Fixed category documentation This patch fixes a minor issue in the documentation of the feed's `category(...)` method. --- feedgen/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 1a626c5..0450304 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -635,7 +635,7 @@ def category(self, category=None, replace=False, **kwargs): If a label is present it is used for the RSS feeds. Otherwise the term is used. The scheme is used for the domain attribute in RSS. - :param link: Dict or list of dicts with data. + :param category: Dict or list of dicts with data. :param replace: Add or replace old data. :returns: List of category data. ''' From b4999f47619f8939ed280628eb5ebbfde2f2da80 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 24 Dec 2023 11:43:47 +0100 Subject: [PATCH 44/47] Fixex generating Atom feed when adding description as summary This patch fixes the problem that generating an Atom feed fails when adding an RSS description marked as summary. The problem was that feedgen internally expected a dictionary, but the value was stored as a string in this case. This fixes #94 --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index f7a13a8..5dd21c2 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -499,7 +499,7 @@ def description(self, description=None, isSummary=False): if description is not None: self.__rss_description = description if isSummary: - self.__atom_summary = description + self.__atom_summary = {'summary': description} else: self.__atom_content = {'content': description} return self.__rss_description From cf6af3d1095800b8352e4f6d931c292994eb6062 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 24 Dec 2023 15:36:17 +0100 Subject: [PATCH 45/47] Include tests in release tarball This patch ensures the tests ader added to the release tarballs of upcoming releases. This fixes #98 --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index c6c12dc..a7c625e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include license.bsd license.lgpl readme.rst recursive-include docs *.html *.css *.png *.gif *.js +recursive-include tests *.py From fc5b64a2453fccfee4d2036d2f26578c5f7c7f41 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 25 Dec 2023 18:51:18 +0100 Subject: [PATCH 46/47] Update RPM Specfile (tests, pypi, py3) This patch updates the RPM specfile to make sure it uses modern macros used for Python packaging. It also switches to the tarballs published on PyPi which will include the tests in future versions. Finally, this drops support of Python 2 which has reached end of life quite a while ago. --- python-feedgen.spec | 53 +++++++++++++++------------------------------ 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/python-feedgen.spec b/python-feedgen.spec index b2e6515..a137396 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,38 +1,24 @@ %global pypi_name feedgen +%global pypi_version 1.0.0 Name: python-%{pypi_name} -Version: 0.9.0 +Version: %{pypi_version} Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) License: BSD or LGPLv3 URL: http://lkiesow.github.io/python-feedgen -Source0: https://github.com/lkiesow/%{name}/archive/v%{version}.tar.gz +#Source0: https://github.com/lkiesow/%{name}/archive/v%{version}.tar.gz +Source0: %{pypi_source} BuildArch: noarch -BuildRequires: python2-dateutil -BuildRequires: python2-devel -BuildRequires: python2-lxml -BuildRequires: python2-setuptools - -BuildRequires: python3-dateutil BuildRequires: python3-devel -BuildRequires: python3-lxml -BuildRequires: python3-setuptools +BuildRequires: python3dist(setuptools) +BuildRequires: python3dist(lxml) +BuildRequires: python3dist(python-dateutil) %description -Feedgenerator This module can be used to generate web feeds in both ATOM and -RSS format. It has support for extensions. Included is for example an extension -to produce Podcasts. - -%package -n python2-%{pypi_name} -Summary: %{summary} -%{?python_provide:%python_provide python2-%{pypi_name}} - -Requires: python2-dateutil -Requires: python2-lxml -%description -n python2-%{pypi_name} -Feedgenerator This module can be used to generate web feeds in both ATOM and +Feedgenerator: This module can be used to generate web feeds in both ATOM and RSS format. It has support for extensions. Included is for example an extension to produce Podcasts. @@ -40,38 +26,29 @@ to produce Podcasts. Summary: %{summary} %{?python_provide:%python_provide python3-%{pypi_name}} -Requires: python3-dateutil -Requires: python3-lxml +Requires: python3dist(python-dateutil) +Requires: python3dist(lxml) + %description -n python3-%{pypi_name} -Feedgenerator This module can be used to generate web feeds in both ATOM and +Feedgenerator: This module can be used to generate web feeds in both ATOM and RSS format. It has support for extensions. Included is for example an extension to produce Podcasts. %prep -%autosetup +%autosetup -n %{pypi_name}-%{pypi_version} # Remove bundled egg-info rm -rf %{pypi_name}.egg-info %build -%py2_build %py3_build %install -%py2_install %py3_install - %check -%{__python2} setup.py test %{__python3} setup.py test -%files -n python2-%{pypi_name} -%license license.lgpl license.bsd -%doc readme.rst -%{python2_sitelib}/%{pypi_name} -%{python2_sitelib}/%{pypi_name}-%{version}-py?.?.egg-info - %files -n python3-%{pypi_name} %license license.lgpl license.bsd %doc readme.rst @@ -79,6 +56,10 @@ rm -rf %{pypi_name}.egg-info %{python3_sitelib}/%{pypi_name}-%{version}-py?.?.egg-info %changelog +* Mon Dec 25 2023 Lars Kiesow - 1.0.0-1 +- Update to 1.0.0 +- Removing support for Python 2 + * Sat May 19 2018 Lars Kiesow - 0.7.0-1 - Update to 0.7.0 From 97260abb1793eb164c458c10b493690beb413f6d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 25 Dec 2023 18:59:03 +0100 Subject: [PATCH 47/47] Release 1.0.0 --- feedgen/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/version.py b/feedgen/version.py index 2a59ec0..c8f76bf 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 9, 0) +version = (1, 0, 0) 'Version of python-feedgen represented as string'