diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c573c55 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + - push + - pull_request + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: set up python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: install system dependencies + run: | + sudo apt update + sudo apt install python3-lxml python3-dateutil + + - name: install Python dependencies + run: | + pip install bandit flake8 coverage liccheck + + - name: install feedgen + run: | + python setup.py install + + - name: run linter + run: make test + + - name: run license check + run: liccheck -s .licenses.ini + + - name: run tests + run: | + python -m feedgen + python -m feedgen atom + python -m feedgen rss + + + - name: run coverage + run: coverage report --fail-under=93 + diff --git a/.gitignore b/.gitignore index 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/ 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 deleted file mode 100644 index 0061fdc..0000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: python - -sudo: false - -python: - - "2.7" - - "3.3" - - "3.4" - - "3.5" - - "3.6" - -install: - - pip install flake8 python-coveralls coverage - - python setup.py bdist_wheel - - pip install dist/feedgen* - -script: - - make test - - python -m feedgen - - python -m feedgen atom - - python -m feedgen rss - -after_success: - - coveralls 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 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 diff --git a/SECURITY.rst b/SECURITY.rst new file mode 100644 index 0000000..7af7bb8 --- /dev/null +++ b/SECURITY.rst @@ -0,0 +1,17 @@ +Security Policy +=============== + +Supported Versions +------------------ + +Only the latest version of this library is supported. +We are doing our best to make updates as easy as possible +so that keeping up-to-date is usually pretty easy. + + +Reporting a Vulnerability +------------------------- + +If you find a security vulnerability, +please report it by sending a mail to security@lkiesow.de. +We will discuss the problem internally and, if necessary, release a patched version as soon as possible. diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 35e7229..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 @@ -99,7 +97,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. diff --git a/feedgen/__main__.py b/feedgen/__main__.py index abc0737..d7dd1aa 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.''') @@ -100,7 +100,12 @@ 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) + 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/entry.py b/feedgen/entry.py index 92fb5f8..5dd21c2 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,15 +13,53 @@ 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): + """Add a text subelement to an entry""" + if not data: + return + + elm = xml_elem(name, entry) + type_ = data.get('type') + if data.get('src'): + if name != 'content': + raise ValueError("Only the 'content' element of an entry can " + "contain a 'src' attribute") + elm.attrib['src'] = data['src'] + elif data.get(name): + # Surround xhtml with a div tag, parse it and embed it + if type_ == 'xhtml': + xhtml = '
' \ + + data.get(name) + '
' + elm.append(xml_fromstring(xhtml)) + elif type_ == 'CDATA': + elm.text = CDATA(data.get(name)) + # Parse XML and embed it + elif type_ and (type_.endswith('/xml') or type_.endswith('+xml')): + elm.append(xml_fromstring(data[name])) + # Embed the text in escaped form + elif not type_ or type_.startswith('text') or type_ == 'html': + elm.text = data.get(name) + # Everything else should be included base64 encoded + else: + raise NotImplementedError( + 'base64 encoded {} is not supported at the moment. ' + 'Pull requests adding support are welcome.'.format(name) + ) + # Add type description of the content + if type_: + elm.attrib['type'] = type_ class FeedEntry(object): - '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item - node. + '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds + item node. ''' def __init__(self): @@ -63,22 +101,22 @@ 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 # 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 @@ -86,65 +124,35 @@ 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') - 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 - - for l in self.__atom_link or []: - link = etree.SubElement(entry, 'link', href=l['href']) - if l.get('rel'): - link.attrib['rel'] = l['rel'] - if l.get('type'): - link.attrib['type'] = l['type'] - if l.get('hreflang'): - link.attrib['hreflang'] = l['hreflang'] - if l.get('title'): - link.attrib['title'] = l['title'] - if l.get('length'): - link.attrib['length'] = l['length'] - - if self.__atom_summary: - summary = etree.SubElement(entry, 'summary') - summary.text = self.__atom_summary + _add_text_elm(entry, self.__atom_content, 'content') + + for link in self.__atom_link or []: + link = xml_elem('link', entry, href=link['href']) + if link.get('rel'): + link.attrib['rel'] = link['rel'] + if link.get('type'): + link.attrib['type'] = link['type'] + if link.get('hreflang'): + link.attrib['hreflang'] = link['hreflang'] + if link.get('title'): + link.attrib['title'] = link['title'] + if link.get('length'): + link.attrib['length'] = link['length'] + + _add_text_elm(entry, self.__atom_summary, 'summary') for c in self.__atom_category or []: - cat = 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'): @@ -155,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 []: @@ -191,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: @@ -367,7 +373,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 @@ -443,17 +449,17 @@ 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 - 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 +473,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): @@ -488,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 @@ -563,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 @@ -657,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): 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/dc.py b/feedgen/ext/dc.py index bc4cb7f..466c66d 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): @@ -92,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: @@ -140,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: @@ -159,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: @@ -177,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: @@ -196,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: @@ -215,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: @@ -234,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: @@ -253,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: @@ -271,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: @@ -290,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: @@ -314,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: @@ -332,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: @@ -350,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: @@ -369,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 8c9dd15..4c18cfe 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -9,9 +9,48 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' +import numbers +import warnings -from lxml import etree from feedgen.ext.base import BaseEntryExtension +from feedgen.util import xml_elem + + +class GeoRSSPolygonInteriorWarning(Warning): + """ + Simple placeholder for warning about ignored polygon interiors. + + Stores the original geom on a ``geom`` attribute (if required warnings are + raised as errors). + """ + + def __init__(self, geom, *args, **kwargs): + self.geom = geom + super(GeoRSSPolygonInteriorWarning, self).__init__(*args, **kwargs) + + def __str__(self): + return '{:d} interiors of polygon ignored'.format( + len(self.geom.__geo_interface__['coordinates']) - 1 + ) # ignore exterior in count + + +class GeoRSSGeometryError(ValueError): + """ + Subclass of ValueError for a GeoRSS geometry error + + Only some geometries are supported in Simple GeoRSS, so if not raise an + error. Offending geometry is stored on the ``geom`` attribute. + """ + + def __init__(self, geom, *args, **kwargs): + self.geom = geom + super(GeoRSSGeometryError, self).__init__(*args, **kwargs) + + def __str__(self): + msg = "Geometry of type '{}' not in Point, Linestring or Polygon" + return msg.format( + self.geom.__geo_interface__['type'] + ) class GeoEntryExtension(BaseEntryExtension): @@ -19,8 +58,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. @@ -31,9 +86,45 @@ 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 = xml_elem('{%s}line' % GEO_NS, entry) + line.text = self.__line + + if self.__polygon: + polygon = xml_elem('{%s}polygon' % GEO_NS, entry) + polygon.text = self.__polygon + + if self.__box: + box = xml_elem('{%s}box' % GEO_NS, entry) + box.text = self.__box + + if self.__featuretypetag: + featuretypetag = xml_elem('{%s}featuretypetag' % GEO_NS, entry) + featuretypetag.text = self.__featuretypetag + + if self.__relationshiptag: + relationshiptag = xml_elem('{%s}relationshiptag' % GEO_NS, entry) + relationshiptag.text = self.__relationshiptag + + if self.__featurename: + featurename = xml_elem('{%s}featurename' % GEO_NS, entry) + featurename.text = self.__featurename + + if self.__elev: + elevation = xml_elem('{%s}elev' % GEO_NS, entry) + elevation.text = str(self.__elev) + + if self.__floor: + floor = xml_elem('{%s}floor' % GEO_NS, entry) + floor.text = str(self.__floor) + + if self.__radius: + radius = xml_elem('{%s}radius' % GEO_NS, entry) + radius.text = str(self.__radius) + return entry def extend_rss(self, entry): @@ -53,3 +144,186 @@ 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=None): + ''' + Get or set the georss:featuretypetag of the entry + + :param featuretypetag: The GeoRSS feaaturertyptag (e.g. "city") + :return: The current georss:featurertypetag + ''' + if featuretypetag is not None: + self.__featuretypetag = featuretypetag + + return self.__featuretypetag + + def relationshiptag(self, relationshiptag=None): + ''' + Get or set the georss:relationshiptag of the entry + + :param relationshiptag: The GeoRSS relationshiptag (e.g. + "is-centred-at") + :return: the current georss:relationshiptag + ''' + if relationshiptag is not None: + self.__relationshiptag = relationshiptag + + return self.__relationshiptag + + def featurename(self, featurename=None): + ''' + Get or set the georss:featurename of the entry + + :param featuretypetag: The GeoRSS featurename (e.g. "Footscray") + :return: the current georss:featurename + ''' + if featurename is not None: + self.__featurename = featurename + + return self.__featurename + + def elev(self, elev=None): + ''' + Get or set the georss:elev of the entry + + :param elev: The GeoRSS elevation (e.g. 100.3) + :type elev: numbers.Number + :return: the current georss:elev + ''' + if elev is not None: + if not isinstance(elev, numbers.Number): + raise ValueError("elev tag must be numeric: {}".format(elev)) + + self.__elev = elev + + return self.__elev + + def floor(self, floor=None): + ''' + Get or set the georss:floor of the entry + + :param floor: The GeoRSS floor (e.g. 4) + :type floor: int + :return: the current georss:floor + ''' + if floor is not None: + if not isinstance(floor, int): + raise ValueError("floor tag must be int: {}".format(floor)) + + self.__floor = floor + + return self.__floor + + def radius(self, radius=None): + ''' + Get or set the georss:radius of the entry + + :param radius: The GeoRSS radius (e.g. 100.3) + :type radius: numbers.Number + :return: the current georss:radius + ''' + if radius is not None: + if not isinstance(radius, numbers.Number): + raise ValueError( + "radius tag must be numeric: {}".format(radius) + ) + + self.__radius = radius + + return self.__radius + + def geom_from_geo_interface(self, geom): + ''' + Generate a georss geometry from some Python object with a + ``__geo_interface__`` property (see the `geo_interface specification by + Sean Gillies`_geointerface ) + + Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be + easily converted to GeoRSS: + + - Point + - LineString + - Polygon (if there are holes / donuts in the polygons a warning will + be generated + + Other GeoJson types will raise a ``ValueError``. + + .. note:: The geometry is assumed to be x, y as longitude, latitude in + the WGS84 projection. + + .. _geointerface: https://gist.github.com/sgillies/2217756 + .. _geojson: https://geojson.org/ + + :param geom: Geometry object with a __geo_interface__ property + :return: the formatted GeoRSS geometry + ''' + geojson = geom.__geo_interface__ + + if geojson['type'] not in ('Point', 'LineString', 'Polygon'): + raise GeoRSSGeometryError(geom) + + if geojson['type'] == 'Point': + + coords = '{:f} {:f}'.format( + geojson['coordinates'][1], # latitude is y + geojson['coordinates'][0] + ) + return self.point(coords) + + elif geojson['type'] == 'LineString': + + coords = ' '.join( + '{:f} {:f}'.format(vertex[1], vertex[0]) + for vertex in + geojson['coordinates'] + ) + return self.line(coords) + + elif geojson['type'] == 'Polygon': + + if len(geojson['coordinates']) > 1: + warnings.warn(GeoRSSPolygonInteriorWarning(geom)) + + coords = ' '.join( + '{:f} {:f}'.format(vertex[1], vertex[0]) + for vertex in + geojson['coordinates'][0] + ) + return self.polygon(coords) diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py 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..c535392 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): @@ -34,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'} @@ -47,11 +46,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,47 +59,48 @@ 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 + if self.__itunes_type in ('episodic', 'serial'): + type = xml_elem('{%s}type' % ITUNES_NS, channel) + type.text = self.__itunes_type + return rss_feed def itunes_author(self, itunes_author=None): @@ -297,8 +297,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. @@ -323,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/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 4fa6128..2d60c2d 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): @@ -31,6 +30,10 @@ def __init__(self): self.__itunes_order = None self.__itunes_subtitle = None self.__itunes_summary = None + self.__itunes_season = None + self.__itunes_episode = None + self.__itunes_title = None + self.__itunes_episode_type = None def extend_rss(self, entry): '''Add additional fields to an RSS item. @@ -40,44 +43,60 @@ 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 + + if self.__itunes_season: + season = xml_elem('{%s}season' % ITUNES_NS, entry) + season.text = str(self.__itunes_season) + + if self.__itunes_episode: + episode = xml_elem('{%s}episode' % ITUNES_NS, entry) + episode.text = str(self.__itunes_episode) + + if self.__itunes_title: + title = xml_elem('{%s}title' % ITUNES_NS, entry) + title.text = self.__itunes_title + + if self.__itunes_episode_type in ('full', 'trailer', 'bonus'): + episode_type = xml_elem('{%s}episodeType' % ITUNES_NS, entry) + episode_type.text = self.__itunes_episode_type return entry def itunes_author(self, itunes_author=None): @@ -243,3 +262,60 @@ 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 + + def itunes_title(self, itunes_title=None): + '''Get or set the itunes:title value for the podcast episode. + + An episode title specific for Apple Podcasts. Don’t specify the episode + number or season number in this tag. Also, don’t repeat the title of + your show within your episode title. + + :param itunes_title: Episode title specific for Apple Podcasts + :returns: Title specific for Apple Podcast + ''' + if itunes_title is not None: + self.__itunes_title = itunes_title + return self.__itunes_title + + def itunes_episode_type(self, itunes_episode_type=None): + '''Get or set the itunes:episodeType value of the item. This tag should + be used to indicate the episode type. + The three values for this tag are "full", "trailer" and "bonus". + + If an episode is a trailer or bonus content, use this tag. + + Specify full when you are submitting the complete content of your show. + Specify trailer when you are submitting a short, promotional piece of + content that represents a preview of your current show. + Specify bonus when you are submitting extra content for your show (for + example, behind the scenes information or interviews with the cast) or + cross-promotional content for another show. + + :param itunes_episode_type: The episode type + :returns: type of the episode. + ''' + if itunes_episode_type is not None: + if itunes_episode_type not in ('full', 'trailer', 'bonus'): + raise ValueError('Invalid value for episodeType tag') + self.__itunes_episode_type = itunes_episode_type + return self.__itunes_episode_type diff --git a/feedgen/ext/syndication.py b/feedgen/ext/syndication.py 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 710aadc..0450304 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,31 +120,31 @@ 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']) - 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 = 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: - generator = etree.SubElement(feed, 'generator') + if self.__atom_generator and self.__atom_generator.get('value'): + generator = xml_elem('generator', feed) generator.text = self.__atom_generator['value'] if self.__atom_generator.get('uri'): generator.attrib['uri'] = self.__atom_generator['uri'] @@ -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: @@ -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, @@ -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: @@ -397,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, @@ -636,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. ''' @@ -762,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. @@ -803,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. @@ -998,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. @@ -1043,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 ca4ad58..18c1c0e 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -10,12 +10,34 @@ ''' import locale import sys +import lxml # nosec - we configure a safe parser below + +# Configure a safe parser which does not allow XML entity expansion +parser = lxml.etree.XMLParser( + attribute_defaults=False, + dtd_validation=False, + load_dtd=False, + no_network=True, + recover=False, + remove_pis=True, + resolve_entities=False, + huge_tree=False) + + +def xml_fromstring(xmlstring): + return lxml.etree.fromstring(xmlstring, parser) # nosec - safe parser + + +def xml_elem(name, parent=None, **kwargs): + if parent is not None: + return lxml.etree.SubElement(parent, name, **kwargs) + return lxml.etree.Element(name, **kwargs) def ensure_format(val, allowed, required, allowed_values=None, defaults=None): - '''Takes a dictionary or a list of dictionaries and check if all keys are in - the set of allowed keys, if all required keys are present and if the values - of a specific key are ok. + '''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. @@ -31,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: diff --git a/feedgen/version.py b/feedgen/version.py index 1481720..c8f76bf 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 = (1, 0, 0) 'Version of python-feedgen represented as string' diff --git a/license.bsd b/license.bsd index a8e1879..beac4ea 100644 --- a/license.bsd +++ b/license.bsd @@ -1,31 +1,25 @@ +BSD 2-Clause License -Copyright 2011 Lars Kiesow. All rights reserved. -http://www.larskiesow.de +Copyright 2011, Lars Kiesow +All rights reserved. Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: +modification, are permitted provided that the following conditions are met: -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY -EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS -BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation -are those of the authors and should not be interpreted as representing -official policies, either expressed or implied, of everyone working on -this project. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/python-feedgen.spec b/python-feedgen.spec index 0170fd0..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.7.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 diff --git a/readme.rst b/readme.rst index cb8e685..3edeaff 100644 --- a/readme.rst +++ b/readme.rst @@ -2,17 +2,9 @@ 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. +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 @@ -20,9 +12,9 @@ at license.bsd and license.lgpl. More details about the project: -- Repository: https://github.com/lkiesow/python-feedgen -- Documentation: http://lkiesow.github.io/python-feedgen/ -- Python Package Index: https://pypi.python.org/pypi/feedgen/ +- `Repository `_ +- `Documentation `_ +- `Python Package Index `_ ------------ @@ -31,18 +23,10 @@ Installation **Prebuild packages** -If you are running Fedora Linux, RedHat Enterprise Linux, CentOS or Scientific -Linux you can use the RPM Copr repository: - -http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/ - -Simply enable the repository and run:: - - $ yum install python-feedgen - -or for the Python 3 package:: +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:: - $ yum install python3-feedgen + $ dnf install python3-feedgen **Using pip** @@ -57,18 +41,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: @@ -77,22 +63,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 ---------------- @@ -102,31 +92,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 @@ -134,7 +128,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** @@ -142,37 +136,36 @@ 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') -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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..631c4c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +lxml>=4.2.5 +python_dateutil>=2.8.0 diff --git a/setup.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'], diff --git a/tests/test_entry.py b/tests/test_entry.py index 6c9835a..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() == '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,4 +163,18 @@ 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() + fg.title('some title') + fg.id('http://lernfunk.de/media/654322/1') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654322/1') + fe.title('some title') + fe.link(href='http://lernfunk.de/media/654322/1') + fe.summary('

summary

', type='html') + result = fg.atom_str() + expected = b'<p>summary</p>' + self.assertIn(expected, result) diff --git a/tests/test_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..af7dd94 --- /dev/null +++ b/tests/test_extensions/test_dc.py @@ -0,0 +1,31 @@ +import unittest + +from feedgen.feed import FeedGenerator + + +class TestExtensionDc(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('dc') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_entryLoadExtension(self): + fe = self.fg.add_item() + try: + fe.load_extension('dc') + except ImportError: + pass # Extension already loaded + + def test_elements(self): + for method in dir(self.fg.dc): + if method.startswith('dc_'): + m = getattr(self.fg.dc, method) + m(method) + self.assertEqual(m(), [method]) + + self.fg.id('123') + self.assertTrue(self.fg.atom_str()) + self.assertTrue(self.fg.rss_str()) diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py new file mode 100644 index 0000000..5de1f80 --- /dev/null +++ b/tests/test_extensions/test_geo.py @@ -0,0 +1,430 @@ +from itertools import chain +import unittest +import warnings + +from lxml import etree + +from feedgen.feed import FeedGenerator +from feedgen.ext.geo_entry import GeoRSSPolygonInteriorWarning, GeoRSSGeometryError # noqa: E501 + + +class Geom(object): + """ + Dummy geom to make testing easier + + When we use the geo-interface we need a class with a `__geo_interface__` + property. Makes it easier for the other tests as well. + + Ultimately this could be used to generate dummy geometries for testing + a wider variety of values (e.g. with the faker library, or the hypothesis + library) + """ + + def __init__(self, geom_type, coords): + self.geom_type = geom_type + self.coords = coords + + def __str__(self): + if self.geom_type == 'Point': + + coords = '{:f} {:f}'.format( + self.coords[1], # latitude is y + self.coords[0] + ) + return coords + + elif self.geom_type == 'LineString': + + coords = ' '.join( + '{:f} {:f}'.format(vertex[1], vertex[0]) + for vertex in + self.coords + ) + return coords + + elif self.geom_type == 'Polygon': + + coords = ' '.join( + '{:f} {:f}'.format(vertex[1], vertex[0]) + for vertex in + self.coords[0] + ) + return coords + + elif self.geom_type == 'Box': + # box not really supported by GeoJSON, but it's a handy cheat here + # for testing + coords = ' '.join( + '{:f} {:f}'.format(vertex[1], vertex[0]) + for vertex in + self.coords + ) + return coords[:2] + + else: + return 'Not a supported geometry' + + @property + def __geo_interface__(self): + return { + 'type': self.geom_type, + 'coordinates': self.coords + } + + +class TestExtensionGeo(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.point = Geom('Point', [-71.05, 42.36]) + cls.line = Geom('LineString', [[-71.05, 42.36], [-71.15, 42.46]]) + cls.polygon = Geom( + 'Polygon', + [[[-71.05, 42.36], [-71.15, 42.46], [-71.15, 42.36]]] + ) + cls.box = Geom('Box', [[-71.05, 42.36], [-71.15, 42.46]]) + cls.polygon_with_interior = Geom( + 'Polygon', + [ + [ # exterior + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0] + ], + [ # interior + [0.25, 0.25], + [0.25, 0.75], + [0.75, 0.75], + [0.75, 0.25], + [0.25, 0.25] + ] + ] + ) + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('geo') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_point(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.point(str(self.point)) + + self.assertEqual(fe.geo.point(), str(self.point)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + point = root.xpath('/rss/channel/item/georss:point/text()', + namespaces=ns) + self.assertEqual(point, [str(self.point)]) + + def test_line(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.line(str(self.line)) + + self.assertEqual(fe.geo.line(), str(self.line)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + line = root.xpath( + '/rss/channel/item/georss:line/text()', + namespaces=ns + ) + self.assertEqual(line, [str(self.line)]) + + def test_polygon(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.polygon(str(self.polygon)) + + self.assertEqual(fe.geo.polygon(), str(self.polygon)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath( + '/rss/channel/item/georss:polygon/text()', + namespaces=ns + ) + self.assertEqual(poly, [str(self.polygon)]) + + def test_box(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.box(str(self.box)) + + self.assertEqual(fe.geo.box(), str(self.box)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + box = root.xpath( + '/rss/channel/item/georss:box/text()', + namespaces=ns + ) + self.assertEqual(box, [str(self.box)]) + + def test_featuretypetag(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.featuretypetag('city') + + self.assertEqual(fe.geo.featuretypetag(), 'city') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + featuretypetag = root.xpath( + '/rss/channel/item/georss:featuretypetag/text()', + namespaces=ns + ) + self.assertEqual(featuretypetag, ['city']) + + def test_relationshiptag(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.relationshiptag('is-centred-at') + + self.assertEqual(fe.geo.relationshiptag(), 'is-centred-at') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + relationshiptag = root.xpath( + '/rss/channel/item/georss:relationshiptag/text()', + namespaces=ns + ) + self.assertEqual(relationshiptag, ['is-centred-at']) + + def test_featurename(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.featurename('Footscray') + + self.assertEqual(fe.geo.featurename(), 'Footscray') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + featurename = root.xpath( + '/rss/channel/item/georss:featurename/text()', + namespaces=ns + ) + self.assertEqual(featurename, ['Footscray']) + + def test_elev(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.elev(100.3) + + self.assertEqual(fe.geo.elev(), 100.3) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + elev = root.xpath( + '/rss/channel/item/georss:elev/text()', + namespaces=ns + ) + self.assertEqual(elev, ['100.3']) + + def test_elev_fails_nonnumeric(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.elev('100.3') + + def test_floor(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.floor(4) + + self.assertEqual(fe.geo.floor(), 4) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + floor = root.xpath( + '/rss/channel/item/georss:floor/text()', + namespaces=ns + ) + self.assertEqual(floor, ['4']) + + def test_floor_fails_nonint(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.floor(100.3) + + with self.assertRaises(ValueError): + fe.geo.floor('4') + + def test_radius(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.radius(100.3) + + self.assertEqual(fe.geo.radius(), 100.3) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + radius = root.xpath( + '/rss/channel/item/georss:radius/text()', + namespaces=ns + ) + self.assertEqual(radius, ['100.3']) + + def test_radius_fails_nonnumeric(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.radius('100.3') + + def test_geom_from_geointerface_point(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.geom_from_geo_interface(self.point) + + self.assertEqual(fe.geo.point(), str(self.point)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + point = root.xpath('/rss/channel/item/georss:point/text()', + namespaces=ns) + self.assertEqual(point, [str(self.point)]) + + coords = [float(c) for c in point[0].split()] + + try: + self.assertCountEqual( + coords, + self.point.coords + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + self.point.coords + ) + + def test_geom_from_geointerface_line(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.geom_from_geo_interface(self.line) + + self.assertEqual(fe.geo.line(), str(self.line)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + line = root.xpath('/rss/channel/item/georss:line/text()', + namespaces=ns) + self.assertEqual(line, [str(self.line)]) + + coords = [float(c) for c in line[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.line.coords)) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.line.coords)) + ) + + def test_geom_from_geointerface_poly(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.geom_from_geo_interface(self.polygon) + + self.assertEqual(fe.geo.polygon(), str(self.polygon)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath('/rss/channel/item/georss:polygon/text()', + namespaces=ns) + self.assertEqual(poly, [str(self.polygon)]) + + coords = [float(c) for c in poly[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.polygon.coords[0])) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.polygon.coords[0])) + ) + + def test_geom_from_geointerface_fail_other_geom(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(GeoRSSGeometryError): + fe.geo.geom_from_geo_interface(self.box) + + def test_geom_from_geointerface_fail_requires_geo_interface(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(AttributeError): + fe.geo.geom_from_geo_interface(str(self.box)) + + def test_geom_from_geointerface_warn_poly_interior(self): + """ + Test complex polygons warn as expected. Taken from + + https://stackoverflow.com/a/3892301/379566 and + https://docs.python.org/2.7/library/warnings.html#testing-warnings + """ + fe = self.fg.add_item() + fe.title('y') + + with warnings.catch_warnings(record=True) as w: + # Cause all warnings to always be triggered. + warnings.simplefilter("always") + # Trigger a warning. + fe.geo.geom_from_geo_interface(self.polygon_with_interior) + # Verify some things + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, + GeoRSSPolygonInteriorWarning)) + + self.assertEqual(fe.geo.polygon(), str(self.polygon_with_interior)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath('/rss/channel/item/georss:polygon/text()', + namespaces=ns) + self.assertEqual(poly, [str(self.polygon_with_interior)]) + + coords = [float(c) for c in poly[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.polygon_with_interior.coords[0])) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.polygon_with_interior.coords[0])) + ) diff --git a/tests/test_extensions/test_media.py b/tests/test_extensions/test_media.py new file mode 100644 index 0000000..1f5a349 --- /dev/null +++ b/tests/test_extensions/test_media.py @@ -0,0 +1,83 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionMedia(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('media') + self.fg.id('id') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_media_content(self): + fe = self.fg.add_item() + fe.id('id') + fe.title('title') + fe.content('content') + fe.media.content(url='file1.xy') + fe.media.content(url='file2.xy') + fe.media.content(url='file1.xy', group=2) + fe.media.content(url='file2.xy', group=2) + fe.media.content(url='file.xy', group=None) + + ns = {'media': 'http://search.yahoo.com/mrss/', + 'a': 'http://www.w3.org/2005/Atom'} + # Check that we have the item in the resulting RSS + root = etree.fromstring(self.fg.rss_str()) + url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', + namespaces=ns) + self.assertEqual(url, ['file1.xy', 'file1.xy']) + + # There is one without a group + url = root.xpath('/rss/channel/item/media:content[1]/@url', + namespaces=ns) + self.assertEqual(url, ['file.xy']) + + # Check that we have the item in the resulting Atom feed + root = etree.fromstring(self.fg.atom_str()) + url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', + namespaces=ns) + self.assertEqual(url, ['file1.xy', 'file1.xy']) + + fe.media.content(content=[], replace=True) + self.assertEqual(fe.media.content(), []) + + def test_media_thumbnail(self): + fe = self.fg.add_item() + fe.id('id') + fe.title('title') + fe.content('content') + fe.media.thumbnail(url='file1.xy') + fe.media.thumbnail(url='file2.xy') + fe.media.thumbnail(url='file1.xy', group=2) + fe.media.thumbnail(url='file2.xy', group=2) + fe.media.thumbnail(url='file.xy', group=None) + + ns = {'media': 'http://search.yahoo.com/mrss/', + 'a': 'http://www.w3.org/2005/Atom'} + # Check that we have the item in the resulting RSS + root = etree.fromstring(self.fg.rss_str()) + url = root.xpath( + '/rss/channel/item/media:group/media:thumbnail[1]/@url', + namespaces=ns) + self.assertEqual(url, ['file1.xy', 'file1.xy']) + + # There is one without a group + url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url', + namespaces=ns) + self.assertEqual(url, ['file.xy']) + + # Check that we have the item in the resulting Atom feed + root = etree.fromstring(self.fg.atom_str()) + url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url', + namespaces=ns) + self.assertEqual(url, ['file1.xy', 'file1.xy']) + + fe.media.thumbnail(thumbnail=[], replace=True) + self.assertEqual(fe.media.thumbnail(), []) diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py new file mode 100644 index 0000000..84c8c96 --- /dev/null +++ b/tests/test_extensions/test_podcast.py @@ -0,0 +1,106 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionPodcast(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('podcast') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_category_new(self): + self.fg.podcast.itunes_category([{'cat': 'Technology', + 'sub': 'Podcasting'}]) + self.fg.podcast.itunes_explicit('no') + self.fg.podcast.itunes_complete('no') + self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') + self.fg.podcast.itunes_owner('John Doe', 'john@example.com') + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) + scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', + namespaces=ns) + self.assertEqual(cat[0], 'Technology') + self.assertEqual(scat[0], 'Podcasting') + + def test_category(self): + self.fg.podcast.itunes_category('Technology', 'Podcasting') + self.fg.podcast.itunes_explicit('no') + self.fg.podcast.itunes_complete('no') + self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') + self.fg.podcast.itunes_owner('John Doe', 'john@example.com') + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) + scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', + namespaces=ns) + self.assertEqual(cat[0], 'Technology') + self.assertEqual(scat[0], 'Podcasting') + + def test_podcastItems(self): + fg = self.fg + fg.podcast.itunes_author('Lars Kiesow') + fg.podcast.itunes_block('x') + fg.podcast.itunes_complete(False) + fg.podcast.itunes_explicit('no') + fg.podcast.itunes_image('x.png') + fg.podcast.itunes_subtitle('x') + fg.podcast.itunes_summary('x') + fg.podcast.itunes_type('episodic') + self.assertEqual(fg.podcast.itunes_author(), 'Lars Kiesow') + self.assertEqual(fg.podcast.itunes_block(), 'x') + self.assertEqual(fg.podcast.itunes_complete(), 'no') + self.assertEqual(fg.podcast.itunes_explicit(), 'no') + self.assertEqual(fg.podcast.itunes_image(), 'x.png') + self.assertEqual(fg.podcast.itunes_subtitle(), 'x') + self.assertEqual(fg.podcast.itunes_summary(), 'x') + self.assertEqual(fg.podcast.itunes_type(), 'episodic') + + # Check that we have the item in the resulting XML + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns) + self.assertEqual(author, ['Lars Kiesow']) + + def test_podcastEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.podcast.itunes_author('Lars Kiesow') + fe.podcast.itunes_block('x') + fe.podcast.itunes_duration('00:01:30') + fe.podcast.itunes_explicit('no') + fe.podcast.itunes_image('x.png') + fe.podcast.itunes_is_closed_captioned('yes') + fe.podcast.itunes_order(1) + fe.podcast.itunes_subtitle('x') + fe.podcast.itunes_summary('x') + fe.podcast.itunes_season(1) + fe.podcast.itunes_episode(1) + fe.podcast.itunes_title('Podcast Title') + fe.podcast.itunes_episode_type('full') + self.assertEqual(fe.podcast.itunes_author(), 'Lars Kiesow') + self.assertEqual(fe.podcast.itunes_block(), 'x') + self.assertEqual(fe.podcast.itunes_duration(), '00:01:30') + self.assertEqual(fe.podcast.itunes_explicit(), 'no') + self.assertEqual(fe.podcast.itunes_image(), 'x.png') + self.assertTrue(fe.podcast.itunes_is_closed_captioned()) + self.assertEqual(fe.podcast.itunes_order(), 1) + self.assertEqual(fe.podcast.itunes_subtitle(), 'x') + self.assertEqual(fe.podcast.itunes_summary(), 'x') + self.assertEqual(fe.podcast.itunes_season(), 1) + self.assertEqual(fe.podcast.itunes_episode(), 1) + self.assertEqual(fe.podcast.itunes_title(), 'Podcast Title') + self.assertEqual(fe.podcast.itunes_episode_type(), 'full') + + # Check that we have the item in the resulting XML + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + author = root.xpath('/rss/channel/item/itunes:author/text()', + namespaces=ns) + self.assertEqual(author, ['Lars Kiesow']) diff --git a/tests/test_extensions/test_syndication.py b/tests/test_extensions/test_syndication.py new file mode 100644 index 0000000..029e100 --- /dev/null +++ b/tests/test_extensions/test_syndication.py @@ -0,0 +1,40 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionSyndication(unittest.TestCase): + + SYN_NS = {'sy': 'http://purl.org/rss/1.0/modules/syndication/'} + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('syndication') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_update_period(self): + for period_type in ('hourly', 'daily', 'weekly', 'monthly', 'yearly'): + self.fg.syndication.update_period(period_type) + root = etree.fromstring(self.fg.rss_str()) + a = root.xpath('/rss/channel/sy:UpdatePeriod', + namespaces=self.SYN_NS) + self.assertEqual(a[0].text, period_type) + + def test_update_frequency(self): + for frequency in (1, 100, 2000, 100000): + self.fg.syndication.update_frequency(frequency) + root = etree.fromstring(self.fg.rss_str()) + a = root.xpath('/rss/channel/sy:UpdateFrequency', + namespaces=self.SYN_NS) + self.assertEqual(a[0].text, str(frequency)) + + def test_update_base(self): + base = '2000-01-01T12:00+00:00' + self.fg.syndication.update_base(base) + root = etree.fromstring(self.fg.rss_str()) + a = root.xpath('/rss/channel/sy:UpdateBase', namespaces=self.SYN_NS) + self.assertEqual(a[0].text, base) diff --git a/tests/test_extensions/test_torrent.py b/tests/test_extensions/test_torrent.py new file mode 100644 index 0000000..230ec1a --- /dev/null +++ b/tests/test_extensions/test_torrent.py @@ -0,0 +1,38 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionTorrent(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('torrent') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_podcastEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.torrent.filename('file.xy') + fe.torrent.infohash('123') + fe.torrent.contentlength('23') + fe.torrent.seeds('1') + fe.torrent.peers('2') + fe.torrent.verified('1') + self.assertEqual(fe.torrent.filename(), 'file.xy') + self.assertEqual(fe.torrent.infohash(), '123') + self.assertEqual(fe.torrent.contentlength(), '23') + self.assertEqual(fe.torrent.seeds(), '1') + self.assertEqual(fe.torrent.peers(), '2') + self.assertEqual(fe.torrent.verified(), '1') + + # Check that we have the item in the resulting XML + ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} + root = etree.fromstring(self.fg.rss_str()) + filename = root.xpath('/rss/channel/item/torrent:filename/text()', + namespaces=ns) + self.assertEqual(filename, ['file.xy']) diff --git a/tests/test_feed.py b/tests/test_feed.py 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 efea777..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': + 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)