diff --git a/.gitignore b/.gitignore index cf0758e..9252c70 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,10 @@ parts *.pyc *.pyo *.so +.coverage* .installed.cfg +default.profraw develop-eggs/ eggs/ *.egg-info/ +.tox/ diff --git a/CHANGES.txt b/CHANGES.txt index 69d8576..46353b8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,13 +1,25 @@ Changelog ========= -2.14.0 (unreleased) -------------------- +3.0 (unreleased) +---------------- +- Reimplemented using Python's ``email`` module + +- Added support for Python 3.5, 3.6, 3.7 and 3.8 + +- Switched documentation to standard ``dtml`` notation + +- Added ``tox`` testing configuration + +- Full linting + +- Boosted test coverage to over 90% + +- Compatible with DocumentTemplate 3.x and Zope 4 2.13.0 (2010-07-10) ------------------- - - PEP8 cleanup and added basic tests. - Released as separate package. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..4300a02 --- /dev/null +++ b/README.rst @@ -0,0 +1,102 @@ +Products.MIMETools +================== + +Currently, the MIMETools product's only function is to provide the +```` DTML tag for the DocumentTemplate distribution. + +The ```` tag is used to construct MIME containers. The syntax of the +```` tag is:: + + + Contents of first part + + Contents of second part + + Contents of nth part + + +The area of data between tags, called a block, is encoded into whatever is +specified with the 'encode' tag attribute for that block. If no encoding is +specified, 'base64' is defaulted. Valid encoding options include 'base64', +'quoted-printable' and '7bit' . If the 'encode' attribute is set to '7bit' +no encoding is done on the block and the data is assumed to be in a valid MIME +format. + +If the 'disposition' attribute is not specified for a certain block, then the +'Content-Disposition:' MIME header is not included in that block's MIME part. + +The entire MIME container, from the opening mime tag to the closing, has it's +'Content-Type:' MIME header set to 'multipart/mixed'. + +For example, the following DTML:: + + + This is the first part. + + This is the second. + + +Is rendered to the following text:: + + Content-Type: multipart/mixed; + boundary="216.164.72.30.501.1550.923070182.795.22531" + + --216.164.72.30.501.1550.923070182.795.22531 + Content-Type: text/plain + Content-Transfer-Encoding: 7bit + + This is the first part. + + --216.164.72.30.501.1550.923070182.795.22531 + Content-Type: text/plain + Content-Transfer-Encoding: base64 + + VGhpcyBpcyB0aGUgc2Vjb25kLgo= + + --216.164.72.30.501.1550.923070182.795.22531-- + +The ``dtml-mime`` tag is particularly handy in conjunction with the +``dtml-sendmail`` tag. This allows Zope to send attachments along with email. +Here is an example. + +Create a DTML method called 'input' with the following code:: + + +
+
+ Send to:
+ +
+ + +Create another DTML Method called 'send' with the following code:: + + + + From: michel@digicool.com + To: + + + Hi , someone sent you this attachment. + + + + + + Mail with attachment was sent. + + + +Notice that there is no blank line between the 'To:' header and the starting +``dtml-mime`` tag. If a blank line is inserted between them then the message +will not be interpreted as multipart by the receiving mail reader. + +Also notice that there is no newline between the ``dtml-boundary`` tag and the +``dtml-var`` tag, or the end of the ``dtml-var`` tag and the closing +``dtml-mime`` tag. This is important, if you break the tags up with newlines +then they will be encoded and included in the MIME part, which is probably not +what you want. + +As per the MIME spec, ``dtml-mime`` tags may be nested within ``dtml-mime`` +tags arbitrarily. diff --git a/README.txt b/README.txt deleted file mode 100644 index 6277275..0000000 --- a/README.txt +++ /dev/null @@ -1,99 +0,0 @@ -Overview -======== - -Currently, the MIMETools product's only function is to provide the -```` DTML tag for the DocumentTemplate distribution. - -The ```` tag is used to construct MIME containers. The syntax of the -```` tag is:: - - - Contents of first part - - Contents of second part - - Contents of nth part - - -The area of data between tags, called a block, is encoded into whatever is -specified with the 'encode' tag attribute for that block. If no encoding is -specified, 'base64' is defaulted. Valid encoding options include 'base64', -'quoted-printable', 'uuencode', 'x-uuencode', 'uue' and 'x-uue'. If the 'encode' -attribute is set to '7bit' no encoding is done on the block and the data is -assumed to be in a valid MIME format. - -If the 'disposition' attribute is not specified for a certain block, then the -'Content-Disposition:' MIME header is not included in that block's MIME part. - -The entire MIME container, from the opening mime tag to the closing, has it's -'Content-Type:' MIME header set to 'multipart/mixed'. - -For example, the following DTML:: - - - This is the first part. - - This is the second. - - -Is rendered to the following text:: - - Content-Type: multipart/mixed; - boundary="216.164.72.30.501.1550.923070182.795.22531" - - --216.164.72.30.501.1550.923070182.795.22531 - Content-Type: text/plain - Content-Transfer-Encoding: 7bit - - This is the first part. - - --216.164.72.30.501.1550.923070182.795.22531 - Content-Type: text/plain - Content-Transfer-Encoding: base64 - - VGhpcyBpcyB0aGUgc2Vjb25kLgo= - - --216.164.72.30.501.1550.923070182.795.22531-- - -The #mime tag is particularly handy in conjunction with the ``#sendmail`` tag. -This allows Zope to send attachments along with email. Here is an example. - -Create a DTML method called 'input' with the following code:: - - -
-
- Send to:
- -
- - -Create another DTML Method called 'send' with the following code:: - - - - From: michel@digicool.com - To: - - - Hi , someone sent you this attachment. - - - - - - Mail with attachment was sent. - - - -Notice that there is no blank line between the 'To:' header and the starting -#mime tag. If a blank line is inserted between them then the message will not be -interpreted as multipart by the receiving mail reader. - -Also notice that there is no newline between the #boundary tag and the #var tag, -or the end of the #var tag and the closing #mime tag. This is important, if you -break the tags up with newlines then they will be encoded and included in the -MIME part, which is probably not what you're after. - -As per the MIME spec, #mime tags may be nested within #mime tags arbitrarily. diff --git a/buildout.cfg b/buildout.cfg index 08e9ad0..29ac703 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -1,12 +1,17 @@ [buildout] develop = . -parts = interpreter test +parts = + test + tox -[interpreter] -recipe = zc.recipe.egg -interpreter = python -eggs = Products.MIMETools [test] recipe = zc.recipe.testrunner eggs = Products.MIMETools + +[tox] +recipe = zc.recipe.egg +eggs = + tox +scripts = + tox diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..a53d350 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,42 @@ +[bdist_wheel] +universal=1 + +[check-manifest] +ignore = + coverage.xml + tox.ini + .travis.yml + +[isort] +force_single_line = True +combine_as_imports = True +sections = FUTURE,STDLIB,THIRDPARTY,ZOPE,FIRSTPARTY,LOCALFOLDER +known_third_party = six +known_zope = DocumentTemplate +default_section = ZOPE +line_length = 79 +lines_after_imports = 2 +not_skip = + __init__.py + +[flake8] +no-accept-encodings = True +doctests = True +exclude = + bootstrap.py +htmldir = parts/flake8 + +[coverage:run] +branch = True +source = src +omit = + +[coverage:report] +fail_under = 85.00 +ignore_errors = True +precision = 2 +show_missing = False +sort = Name + +[coverage:html] +directory = parts/coverage diff --git a/setup.py b/setup.py index 37dbf4f..24b8b2b 100644 --- a/setup.py +++ b/setup.py @@ -12,25 +12,51 @@ # ############################################################################## -from setuptools import setup, find_packages +from setuptools import find_packages +from setuptools import setup + setup(name='Products.MIMETools', - version = '2.14.0.dev0', - url='http://pypi.python.org/pypi/Products.MIMETools', + version='3.0.dev0', + url='https://github.com/zopefoundation/Products.MIMETools', + project_urls={ + 'Issue Tracker': ('https://github.com/zopefoundation' + '/Products.MIMETools/issues'), + 'Sources': 'https://github.com/zopefoundation/Products.MIMETools', + }, license='ZPL 2.1', - description="MIMETools provides the <!--#mime--> tag for " - "DocumentTemplate.", + description='MIMETools provides the ``dtml-mime`` tag for ' + 'DocumentTemplate.', author='Zope Foundation and Contributors', author_email='zope-dev@zope.org', - long_description=open('README.txt').read() + '\n' + - open('CHANGES.txt').read(), + long_description=(open('README.rst').read() + '\n' + + open('CHANGES.txt').read()), packages=find_packages('src'), namespace_packages=['Products'], package_dir={'': 'src'}, + classifiers=[ + 'Development Status :: 6 - Mature', + 'Environment :: Web Environment', + 'Framework :: Zope', + 'Framework :: Zope :: 4', + 'License :: OSI Approved :: Zope Public License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: Implementation :: CPython', + 'Topic :: Communications :: Email', + ], + python_requires='>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*', install_requires=[ 'setuptools', + 'six', 'ExtensionClass>=4.1a1', - 'DocumentTemplate', + 'DocumentTemplate>=3', ], include_package_data=True, zip_safe=False, diff --git a/src/Products/MIMETools/MIMETag.py b/src/Products/MIMETools/MIMETag.py index f9148d7..82b6663 100644 --- a/src/Products/MIMETools/MIMETag.py +++ b/src/Products/MIMETools/MIMETag.py @@ -11,31 +11,57 @@ # ############################################################################## -from cStringIO import StringIO -import mimetools +from email.encoders import encode_7or8bit +from email.encoders import encode_base64 +from email.encoders import encode_quopri +from email.mime.application import MIMEApplication +from email.mime.audio import MIMEAudio +from email.mime.image import MIMEImage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from io import BytesIO +from io import StringIO + +import six +from DocumentTemplate.DT_String import String from DocumentTemplate.DT_Util import Eval -from DocumentTemplate.DT_Util import parse_params from DocumentTemplate.DT_Util import ParseError +from DocumentTemplate.DT_Util import parse_params from DocumentTemplate.DT_Util import render_blocks -from DocumentTemplate.DT_String import String + + +if six.PY2: + outfile = BytesIO +else: + outfile = StringIO +TYPE_CLASSES = { + 'text': MIMEText, + 'image': MIMEImage, + 'audio': MIMEAudio, + 'application': MIMEApplication, +} +ENCODINGS = { + '7bit': encode_7or8bit, + '8bit': encode_7or8bit, + 'base64': encode_base64, + 'quoted-printable': encode_quopri, +} class MIMEError(Exception): """MIME Tag Error""" -ENCODINGS = ('base64', 'quoted-printable', 'uuencode', 'x-uuencode', 'uue', - 'x-uue', '7bit') - class MIMETag(object): - '''''' + """ The dtml-mime tag """ - name='mime' - blockContinuations=('boundary', ) - encode=None + name = 'mime' + blockContinuations = ('boundary',) + encode = None - def __init__(self, blocks): + def __init__(self, blocks, encoding=None): + self.encoding = encoding self.sections = [] self.multipart = 'mixed' @@ -61,30 +87,29 @@ def __init__(self, blocks): if 'type_expr' in args: if 'type' in args: - raise ParseError(_tm('type and type_expr given', 'mime')) + raise ParseError('dtml-mime: type and type_expr given') args['type_expr'] = Eval(args['type_expr']) elif 'type' not in args: - args['type']='application/octet-stream' + args['type'] = 'application/octet-stream' if 'disposition_expr' in args: if 'disposition' in args: raise ParseError( - _tm('disposition and disposition_expr given', 'mime')) + 'dtml-mime: disposition and disposition_expr given') args['disposition_expr'] = Eval(args['disposition_expr']) elif 'disposition' not in args: args['disposition'] = '' if 'encode_expr' in args: if 'encode' in args: - raise ParseError( - _tm('encode and encode_expr given', 'mime')) + raise ParseError('dtml-mime: encode and encode_expr given') args['encode_expr'] = Eval(args['encode_expr']) elif 'encode' not in args: args['encode'] = 'base64' if 'name_expr' in args: if 'name' in args: - raise ParseError(_tm('name and name_expr given', 'mime')) + raise ParseError('dtml-mime: name and name_expr given') args['name_expr'] = Eval(args['name_expr']) elif 'name' not in args: args['name'] = '' @@ -92,14 +117,14 @@ def __init__(self, blocks): if 'filename_expr' in args: if 'filename' in args: raise ParseError( - _tm('filename and filename_expr given', 'mime')) + 'dtml-mime: filename and filename_expr given') args['filename_expr'] = Eval(args['filename_expr']) elif 'filename' not in args: args['filename'] = '' if 'cid_expr' in args: if 'cid' in args: - raise ParseError(_tm('cid and cid_expr given', 'mime')) + raise ParseError('dtml-mime: cid and cid_expr given') args['cid_expr'] = Eval(args['cid_expr']) elif 'cid' not in args: args['cid'] = '' @@ -107,10 +132,10 @@ def __init__(self, blocks): if 'charset_expr' in args: if 'charset' in args: raise ParseError( - _tm('charset and charset_expr given', 'mime')) + 'dtml-mime: charset and charset_expr given') args['charset_expr'] = Eval(args['charset_expr']) elif 'charset' not in args: - args['charset'] = '' + args['charset'] = 'us-ascii' # Default for text parts if 'skip_expr' in args: args['skip_expr'] = Eval(args['skip_expr']) @@ -121,95 +146,70 @@ def __init__(self, blocks): self.sections.append((args, section.blocks)) def render(self, md): - from MimeWriter import MimeWriter # deprecated since Python 2.3! - IO = StringIO() - IO.write("Mime-Version: 1.0\n") - mw = MimeWriter(IO) - outer = mw.startmultipartbody(self.multipart) - - last = None - for x in self.sections: - a, b = x - if 'skip_expr' in a and a['skip_expr'].eval(md): - continue - - inner = mw.nextpart() + outer = MIMEMultipart(self.multipart) - if 'type_expr' in a: - t = a['type_expr'].eval(md) - else: - t = a['type'] + for (args, blocks) in self.sections: + if 'skip_expr' in args and args['skip_expr'].eval(md): + continue - if 'disposition_expr' in a: - d = a['disposition_expr'].eval(md) + if 'type_expr' in args: + typ = args['type_expr'].eval(md) else: - d = a['disposition'] + typ = args['type'] - if 'encode_expr' in a: - e = a['encode_expr'].eval(md) + if 'disposition_expr' in args: + disposition = args['disposition_expr'].eval(md) else: - e = a['encode'] + disposition = args['disposition'] - if 'name_expr' in a: - n = a['name_expr'].eval(md) + if 'encode_expr' in args: + encode = args['encode_expr'].eval(md) else: - n = a['name'] + encode = args['encode'] - if 'filename_expr' in a: - f = a['filename_expr'].eval(md) + if 'filename_expr' in args: + filename = args['filename_expr'].eval(md) else: - f = a['filename'] + filename = args['filename'] - if 'cid_expr' in a: - cid = a['cid_expr'].eval(md) + if 'cid_expr' in args: + cid = args['cid_expr'].eval(md) else: - cid = a['cid'] + cid = args['cid'] - if 'charset_expr' in a: - charset = a['charset_expr'].eval(md) + if 'charset_expr' in args: + charset = args['charset_expr'].eval(md) else: - charset = a['charset'] + charset = args['charset'] - if d: - if f: - inner.addheader('Content-Disposition', - '%s;\n filename="%s"' % (d, f)) - else: - inner.addheader('Content-Disposition', d) + maintype, subtype = [x.lower() for x in typ.split('/')] + if maintype not in TYPE_CLASSES: + maintype = 'application' + subtype = 'octet-stream' - inner.addheader('Content-Transfer-Encoding', e) + klass = TYPE_CLASSES.get(maintype, MIMEApplication) + data = render_blocks(blocks, md) - if cid: - inner.addheader('Content-ID', '<%s>' % cid) - - if n: - plist = [('name', n)] + if maintype == 'text': + inner = klass(data, _subtype=subtype, _charset=charset) else: - plist = [] - - if t.startswith('text/'): - plist.append(('charset', charset or 'us-ascii')) - - innerfile = inner.startbody(t, plist, 1) + inner = klass(data, _subtype=subtype, + _encoder=ENCODINGS.get(encode)) - output = StringIO() - if e == '7bit': - innerfile.write(render_blocks(b, md)) - else: - mimetools.encode(StringIO(render_blocks(b, md)), - output, e) - output.seek(0) - innerfile.write(output.read()) + if cid: + inner.add_header('Content-ID', '<%s>' % cid) - last = x + if disposition: + if filename: + inner.add_header('Content-Disposition', + '%s;\n filename="%s"' % (disposition, + filename)) + else: + inner.add_header('Content-Disposition', disposition) - # XXX what if self.sections is empty ??? does it matter that - # mw.lastpart() is called right after mw.startmultipartbody() ? - if last is not None and last is self.sections[-1]: - mw.lastpart() + outer.attach(inner) - outer.seek(0) - return outer.read() + return outer.as_string() __call__ = render diff --git a/src/Products/MIMETools/__init__.py b/src/Products/MIMETools/__init__.py index b12bf6d..2ed6bf2 100644 --- a/src/Products/MIMETools/__init__.py +++ b/src/Products/MIMETools/__init__.py @@ -11,4 +11,4 @@ # ############################################################################## -import MIMETag +from . import MIMETag # NOQA: flake8: F401 diff --git a/src/Products/MIMETools/tests.py b/src/Products/MIMETools/tests.py index 5c3f3e8..fa2bc9c 100644 --- a/src/Products/MIMETools/tests.py +++ b/src/Products/MIMETools/tests.py @@ -11,8 +11,14 @@ # ############################################################################## +import base64 +import email import unittest +import six + +from DocumentTemplate.DT_Util import ParseError + class MimeTest(unittest.TestCase): @@ -24,27 +30,189 @@ def _makeOne(self, blocks=[]): klass = self._getTargetClass() return klass(blocks) + def _decode64(self, text): + if six.PY3 and isinstance(text, str): + text = text.encode('utf-8') + try: + return base64.decodebytes(text) + except AttributeError: + return base64.decodestring(text) + + @property + def doc_class(self): + from DocumentTemplate.DT_HTML import HTML + return HTML + def test_registered(self): klass = self._getTargetClass() from DocumentTemplate.DT_String import String - self.failUnless('mime' in String.commands) - self.failUnless(String.commands['mime'] is klass) + self.assertTrue('mime' in String.commands) + self.assertTrue(String.commands['mime'] is klass) def test_init(self): tag = self._makeOne() - self.assertEquals(tag.sections, []) + self.assertEqual(tag.sections, []) def test_render(self): tag = self._makeOne() result = tag.render(md={}) - self.assert_("Mime-Version: 1.0" in result) - self.assert_("Content-Type: multipart/mixed;" in result) + self.assertIn('MIME-Version: 1.0', result) + self.assertIn('Content-Type: multipart/mixed;', result) def test_call(self): tag = self._makeOne() result = tag(md={}) - self.assert_("Mime-Version: 1.0" in result) - self.assert_("Content-Type: multipart/mixed;" in result) + self.assertIn('MIME-Version: 1.0', result) + self.assertIn('Content-Type: multipart/mixed;', result) + + def test_text(self): + html = self.doc_class( + '' + 'I am BOLD' + '' + 'Just plain text' + '' + 'All bells and whistles' + '' + 'You cannot see me' + '') + + msg = email.message_from_string(html(md={})) + self.assertTrue(msg.is_multipart()) + + parts = msg.get_payload() + self.assertEqual(len(parts), 3) + + part1 = parts[0] + self.assertFalse(part1.is_multipart()) + self.assertEqual(part1['Content-Type'], + 'text/html; charset="utf-8"') + self.assertEqual(part1['MIME-Version'], '1.0') + self.assertEqual(part1['Content-Transfer-Encoding'], 'base64') + self.assertEqual(self._decode64(part1.get_payload()), + b'I am BOLD') + + part2 = parts[1] + self.assertFalse(part2.is_multipart()) + self.assertEqual(part2['Content-Type'], + 'text/plain; charset="us-ascii"') + self.assertEqual(part2['MIME-Version'], '1.0') + self.assertEqual(part2['Content-Transfer-Encoding'], '7bit') + self.assertEqual(part2.get_payload(), 'Just plain text') + + part3 = parts[2] + self.assertFalse(part3.is_multipart()) + self.assertEqual(part3['Content-Type'], + 'text/css; charset="iso8859-1"') + self.assertEqual(part3['MIME-Version'], '1.0') + self.assertEqual(part3['Content-Transfer-Encoding'], 'base64') + self.assertEqual(part3['Content-ID'], '') + self.assertEqual(part3['Content-Disposition'], + 'attachment;\n filename="my.css"') + self.assertEqual(self._decode64(part3.get_payload()), + b'All bells and whistles') + + def test_other(self): + html = self.doc_class( + '' + 'I am BOLD' + '' + 'Just plain text' + '' + 'All bells and whistles' + '') + + msg = email.message_from_string(html(md={})) + self.assertTrue(msg.is_multipart()) + + parts = msg.get_payload() + self.assertEqual(len(parts), 3) + + part1 = parts[0] + self.assertFalse(part1.is_multipart()) + self.assertEqual(part1['Content-Type'], + 'application/octet-stream') + self.assertEqual(part1['MIME-Version'], '1.0') + self.assertEqual(part1['Content-Transfer-Encoding'], 'base64') + self.assertEqual(self._decode64(part1.get_payload()), + b'I am BOLD') + + part2 = parts[1] + self.assertFalse(part2.is_multipart()) + self.assertEqual(part2['Content-Type'], 'application/octet-stream') + self.assertEqual(part2['MIME-Version'], '1.0') + self.assertEqual(part2['Content-Transfer-Encoding'], 'base64') + self.assertEqual(self._decode64(part2.get_payload()), + b'Just plain text') + + part3 = parts[2] + self.assertFalse(part3.is_multipart()) + self.assertEqual(part3['Content-Type'], 'application/js') + self.assertEqual(part3['MIME-Version'], '1.0') + self.assertEqual(part3['Content-Transfer-Encoding'], + 'quoted-printable') + self.assertEqual(part3['Content-ID'], '') + self.assertEqual(part3['Content-Disposition'], + 'attachment;\n filename="my.js"') + self.assertEqual(part3.get_payload(), + 'All=20bells=20and=20whistles') + + def test_bad_encoding(self): + from .MIMETag import MIMEError + broken = '' + html = self.doc_class(broken) + with self.assertRaises(MIMEError) as cm: + html() + self.assertIn('unsupported encoding', str(cm.exception)) + + def test_forbidden_combined_attributes(self): + broken = '' + html = self.doc_class(broken) + with self.assertRaises(ParseError) as cm: + html() + self.assertIn('type and type_expr given', str(cm.exception)) + + broken = ('') + html = self.doc_class(broken) + with self.assertRaises(ParseError) as cm: + html() + self.assertIn('disposition and disposition_expr given', + str(cm.exception)) + + broken = '' + html = self.doc_class(broken) + with self.assertRaises(ParseError) as cm: + html() + self.assertIn('encode and encode_expr given', str(cm.exception)) + + broken = '' + html = self.doc_class(broken) + with self.assertRaises(ParseError) as cm: + html() + self.assertIn('name and name_expr given', str(cm.exception)) + + broken = '' + html = self.doc_class(broken) + with self.assertRaises(ParseError) as cm: + html() + self.assertIn('filename and filename_expr given', str(cm.exception)) + + broken = '' + html = self.doc_class(broken) + with self.assertRaises(ParseError) as cm: + html() + self.assertIn('cid and cid_expr given', str(cm.exception)) + + broken = '' + html = self.doc_class(broken) + with self.assertRaises(ParseError) as cm: + html() + self.assertIn('charset and charset_expr given', str(cm.exception)) def test_suite(): diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..638d3eb --- /dev/null +++ b/tox.ini @@ -0,0 +1,61 @@ +[tox] +envlist = + py27, + py35, + py36, + py37, + py38, + lint, + coverage + +[testenv] +commands = + {envbindir}/buildout -c {toxinidir}/buildout.cfg buildout:directory={envdir} buildout:develop={toxinidir} bootstrap + {envbindir}/buildout -c {toxinidir}/buildout.cfg buildout:directory={envdir} buildout:develop={toxinidir} install test + {envbindir}/test +deps = + setuptools + zc.buildout +skip_install = true + +[testenv:coverage] +basepython = python3.6 +commands = + {envbindir}/buildout -c {toxinidir}/buildout.cfg buildout:directory={envdir} buildout:develop={toxinidir} bootstrap + {envbindir}/buildout -c {toxinidir}/buildout.cfg buildout:directory={envdir} buildout:develop={toxinidir} install test + coverage erase --rcfile={toxinidir}/setup.cfg + coverage run --rcfile={toxinidir}/setup.cfg {envbindir}/test -q + - coverage report --rcfile={toxinidir}/setup.cfg + coverage html -i --rcfile={toxinidir}/setup.cfg +deps = + {[testenv]deps} + coverage +setenv = + COVERAGE_FILE={toxinidir}/.coverage + +[testenv:lint] +basepython = python3.6 +commands_pre = + mkdir -p {toxinidir}/parts/flake8 +commands = + isort --check-only --diff --recursive {toxinidir}/src setup.py + - flake8 --format=html src tests setup.py + flake8 src tests setup.py +deps = + isort + flake8 + # helper to generate HTML reports: + flake8-html + # Useful flake8 plugins that are Python and Plone specific: + flake8-coding + flake8-debugger + flake8-deprecated + flake8-todo + flake8-isort + mccabe + flake8-blind-except + flake8-commas + flake8-string-format + flake8-quotes +whitelist_externals = + mkdir