diff --git a/.gitignore b/.gitignore index a5bdcd8..3cb49ad 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ parts/ test.sqlite env pip-log.txt +.tox diff --git a/.travis.yml b/.travis.yml index 2b1000c..cdf877b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,2 +1,19 @@ language: python -script: ./run-tests.sh +python: + - "3.5" + - "3.4" + - "2.7" +sudo: false +env: + - TOX_ENV=django19 + - TOX_ENV=django18 + - TOX_ENV=django110 + - TOX_ENV=djangomaster +matrix: + fast_finish: true + allow_failures: + - env: TOX_ENV=djangomaster +install: + - pip install tox +script: + - tox -e $TOX_ENV diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 40d579d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -Django==1.3.1 -nose==1.1.2 -django-nose==0.1.3 -pycrypto==2.3 --e . diff --git a/run-tests.sh b/run-tests.sh deleted file mode 100755 index 4065856..0000000 --- a/run-tests.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -e - -if [ ! -e 'env' ]; then - python virtualenv.py env - env/bin/pip install -U -r requirements.txt -fi - -env/bin/python src/example/manage.py test $@ diff --git a/setup.py b/setup.py index 8603eb0..1422d43 100644 --- a/setup.py +++ b/setup.py @@ -3,15 +3,21 @@ version = '0.3.0' setup( - name = 'django-fields', - version = version, - description = 'Django-fields is an application which includes different kinds of models fields.', - keywords = 'django apps tools collection', - license = 'New BSD License', - author = 'Alexander Artemenko', - author_email = 'svetlyak.40wt@gmail.com', - url = 'http://github.com/svetlyak40wt/django-fields/', - install_requires = ['pycrypto', ], + name='django-fields', + version=version, + description='Django-fields is an application which includes different kinds of models fields.', + keywords='django apps tools collection', + license='New BSD License', + author='Alexander Artemenko', + author_email='svetlyak.40wt@gmail.com', + url='http://github.com/svetlyak40wt/django-fields/', + install_requires=[ + 'django', + 'pycrypto', + 'nose', + 'django-nose==1.4.4', + 'tox', + ], classifiers=[ 'Development Status :: 2 - Pre-Alpha', 'Environment :: Plugins', @@ -21,9 +27,8 @@ 'Programming Language :: Python', 'Topic :: Software Development :: Libraries :: Python Modules', ], - package_dir = {'': 'src'}, - packages = ['django_fields'], - include_package_data = True, + package_dir={'': 'src'}, + packages=['django_fields'], + include_package_data=True, + test_suite="runtests.runtests", ) - - diff --git a/src/django_fields/fields.py b/src/django_fields/fields.py index 0075a6f..3611510 100644 --- a/src/django_fields/fields.py +++ b/src/django_fields/fields.py @@ -1,4 +1,5 @@ import binascii +import codecs import datetime import string import sys @@ -8,7 +9,6 @@ from django.forms import fields from django.db import models from django.conf import settings -from django.utils.encoding import smart_str, force_unicode from django.utils.translation import ugettext_lazy as _ from Crypto import Random from Crypto.Random import random @@ -28,6 +28,14 @@ except: import pickle +if sys.version_info[0] == 3: + PYTHON3 = True + from django.utils.encoding import smart_str, force_text as force_unicode +else: + PYTHON3 = False + from django.utils.encoding import smart_str, force_unicode + + class BaseEncryptedField(models.Field): '''This code is based on the djangosnippet #1095 You can find the original at http://www.djangosnippets.org/snippets/1095/''' @@ -76,7 +84,12 @@ def __init__(self, *args, **kwargs): super(BaseEncryptedField, self).__init__(*args, **kwargs) def _is_encrypted(self, value): - return isinstance(value, basestring) and value.startswith(self.prefix) + if PYTHON3 is True: + is_enc = isinstance(value, str) and value.startswith(self.prefix) + return is_enc + else: + return isinstance(value, basestring) and value.startswith( + self.prefix) def _get_padding(self, value): # We always want at least 2 chars of padding (including zero byte), @@ -84,7 +97,7 @@ def _get_padding(self, value): mod = (len(value) + 2) % self.cipher.block_size return self.cipher.block_size - mod + 2 - def to_python(self, value): + def from_db_value(self, value, expression, connection, context): if self._is_encrypted(value): if self.block_type: self.iv = binascii.a2b_hex(value[len(self.prefix):])[:len(self.iv)] @@ -96,7 +109,7 @@ def to_python(self, value): else: decrypt_value = binascii.a2b_hex(value[len(self.prefix):]) return force_unicode( - self.cipher.decrypt(decrypt_value).split('\0')[0] + self.cipher.decrypt(decrypt_value).split(b'\0')[0] ) return value @@ -104,23 +117,43 @@ def get_db_prep_value(self, value, connection=None, prepared=False): if value is None: return None - value = smart_str(value) + if PYTHON3 is True: + value = bytes(value.encode('utf-8')) + else: + value = smart_str(value) if not self._is_encrypted(value): padding = self._get_padding(value) if padding > 0: - value += "\0" + ''.join([ - random.choice(string.printable) - for index in range(padding-1) - ]) + if PYTHON3 is True: + value += bytes("\0".encode('utf-8')) + bytes( + ''.encode('utf-8')).join([ + bytes(random.choice( + string.printable).encode('utf-8')) + for index in range(padding - 1)]) + else: + value += "\0" + ''.join([ + random.choice(string.printable) + for index in range(padding - 1) + ]) if self.block_type: self.cipher = self.cipher_object.new( self.secret_key, getattr(self.cipher_object, self.block_type), self.iv) - value = self.prefix + binascii.b2a_hex(self.iv + self.cipher.encrypt(value)) + if PYTHON3 is True: + value = self.prefix + binascii.b2a_hex( + self.iv + self.cipher.encrypt(value)).decode('utf-8') + else: + value = self.prefix + binascii.b2a_hex( + self.iv + self.cipher.encrypt(value)) else: - value = self.prefix + binascii.b2a_hex(self.cipher.encrypt(value)) + if PYTHON3 is True: + value = self.prefix + binascii.b2a_hex( + self.cipher.encrypt(value)).decode('utf-8') + else: + value = self.prefix + binascii.b2a_hex( + self.cipher.encrypt(value)) return value def deconstruct(self): @@ -136,7 +169,6 @@ def deconstruct(self): class EncryptedTextField(BaseEncryptedField): - __metaclass__ = models.SubfieldBase def get_internal_type(self): return 'TextField' @@ -148,7 +180,6 @@ def formfield(self, **kwargs): class EncryptedCharField(BaseEncryptedField): - __metaclass__ = models.SubfieldBase def get_internal_type(self): return "CharField" @@ -191,6 +222,9 @@ def formfield(self, **kwargs): return super(BaseEncryptedDateField, self).formfield(**defaults) def to_python(self, value): + return self.from_db_value(value) + + def from_db_value(self, value, expression, connection, context): # value is either a date or a string in the format "YYYY:MM:DD" if value in fields.EMPTY_VALUES: @@ -199,7 +233,8 @@ def to_python(self, value): if isinstance(value, self.date_class): date_value = value else: - date_text = super(BaseEncryptedDateField, self).to_python(value) + date_text = super(BaseEncryptedDateField, self).from_db_value( + value, expression, connection, context) date_value = self.date_class(*map(int, date_text.split(':'))) return date_value @@ -218,7 +253,6 @@ def get_db_prep_value(self, value, connection=None, prepared=False): class EncryptedDateField(BaseEncryptedDateField): - __metaclass__ = models.SubfieldBase form_widget = forms.DateInput form_field = forms.DateField save_format = "%Y:%m:%d" @@ -228,7 +262,6 @@ class EncryptedDateField(BaseEncryptedDateField): class EncryptedDateTimeField(BaseEncryptedDateField): # FIXME: This doesn't handle time zones, but Python doesn't really either. - __metaclass__ = models.SubfieldBase form_widget = forms.DateTimeInput form_field = forms.DateTimeField save_format = "%Y:%m:%d:%H:%M:%S:%f" @@ -248,11 +281,15 @@ def get_internal_type(self): return 'CharField' def to_python(self, value): + return self.from_db_value(value) + + def from_db_value(self, value, expression, connection, context): # value is either an int or a string of an integer if isinstance(value, self.number_type) or value == '': number = value else: - number_text = super(BaseEncryptedNumberField, self).to_python(value) + number_text = super(BaseEncryptedNumberField, self).from_db_value( + value, expression, connection, context) number = self.number_type(number_text) return number @@ -267,16 +304,20 @@ def get_db_prep_value(self, value, connection=None, prepared=False): class EncryptedIntField(BaseEncryptedNumberField): - __metaclass__ = models.SubfieldBase - max_raw_length = len(str(-sys.maxint - 1)) + if PYTHON3 is True: + max_raw_length = len(str(-sys.maxsize - 1)) + else: + max_raw_length = len(str(-sys.maxint - 1)) number_type = int format_string = "%d" class EncryptedLongField(BaseEncryptedNumberField): - __metaclass__ = models.SubfieldBase max_raw_length = None # no limit - number_type = long + if PYTHON3 is True: + number_type = int + else: + number_type = long format_string = "%d" def get_internal_type(self): @@ -284,7 +325,6 @@ def get_internal_type(self): class EncryptedFloatField(BaseEncryptedNumberField): - __metaclass__ = models.SubfieldBase max_raw_length = 150 # arbitrary, but should be sufficient number_type = float # If this format is too long for some architectures, change it. @@ -292,22 +332,39 @@ class EncryptedFloatField(BaseEncryptedNumberField): class PickleField(models.TextField): - __metaclass__ = models.SubfieldBase - editable = False serialize = False def get_db_prep_value(self, value, connection=None, prepared=False): - return pickle.dumps(value) + if PYTHON3 is True: + # When PYTHON3, we convert data to base64 to prevent errors when + # unpickling. + val = codecs.encode(pickle.dumps(value), 'base64').decode() + return val + else: + return pickle.dumps(value) def to_python(self, value): - if not isinstance(value, basestring): - return value + return self.from_db_value(value) + + def from_db_value(self, value, expression, connection, context): + if PYTHON3 is True: + if not isinstance(value, str): + return value + else: + if not isinstance(value, basestring): + return value # Tries to convert unicode objects to string, cause loads pickle from # unicode excepts ugly ``KeyError: '\x00'``. try: - return pickle.loads(smart_str(value)) + if PYTHON3 is True: + # When PYTHON3, data are in base64 to prevent errors when + # unpickling. + val = pickle.loads(codecs.decode(value.encode(), "base64")) + return val + else: + return pickle.loads(smart_str(value)) # If pickle could not loads from string it's means that it's Python # string saved to PickleField. except ValueError: @@ -317,14 +374,12 @@ def to_python(self, value): class EncryptedUSPhoneNumberField(BaseEncryptedField): - __metaclass__ = models.SubfieldBase - def get_internal_type(self): return "CharField" def formfield(self, **kwargs): try: - from localflavor.us.forms import USPhoneNumberField + from localflavor.us.forms import USPhoneNumberField except ImportError: from django.contrib.localflavor.us.forms import USPhoneNumberField @@ -334,8 +389,6 @@ def formfield(self, **kwargs): class EncryptedUSSocialSecurityNumberField(BaseEncryptedField): - __metaclass__ = models.SubfieldBase - def get_internal_type(self): return "CharField" @@ -343,14 +396,14 @@ def formfield(self, **kwargs): try: from localflavor.us.forms import USSocialSecurityNumberField except ImportError: - from django.contrib.localflavor.us.forms import USSocialSecurityNumberField + from django.contrib.localflavor.us.forms import USSocialSecurityNumberField defaults = {'form_class': USSocialSecurityNumberField} defaults.update(kwargs) return super(EncryptedUSSocialSecurityNumberField, self).formfield(**defaults) + class EncryptedEmailField(BaseEncryptedField): - __metaclass__ = models.SubfieldBase description = _("E-mail address") def get_internal_type(self): diff --git a/src/django_fields/models.py b/src/django_fields/models.py index b1afa50..dd9e691 100644 --- a/src/django_fields/models.py +++ b/src/django_fields/models.py @@ -1,8 +1,14 @@ # -*- coding: utf-8 -*- -import re +import sys + from django.db import models +if sys.version_info[0] == 3: + PYTHON3 = True +else: + PYTHON3 = False + class PrivateFieldsMetaclass(models.base.ModelBase): """Metaclass to set right default db_column values @@ -29,63 +35,3 @@ def __new__(cls, name, bases, attrs): result = super_new(cls, name, bases, attrs) return result - -class ModelWithPrivateFields(models.Model): - """This abstract base class allows you to use "private" fields in django models for better encapsulation. - - It does two things: - - * adds a one more item into the Django's internal name map for the model class; - * mangles constructor kwargs so that short names could be used. - - The first thing needs to make `filter`, `get` and other methods accept short field names. - For example, let you want to use this class: - - class TestModel(ModelWithPrivateFields): - __state = models.CharField(max_length=255, editable=False) - - Then you will not be able to execute `TestModel.objects.filter(state='blah')` and - Django will not allow you to run `TestModel.objects.filter(_TestModel__state='blah')` - because it will see `__` in the param and try to make join using missing field `_TestModel`. - - Monkey patching of the `init_name_map` method, resolves this problem. - - Another obstacle is that you can't pass short field names into the class's constructor like that: - `obj = TestModel(state='blah')`, because Django does not know about `state` attribute, but is - aware of `_TestModel__state`. That is why `PrivateFieldsMetaclass` mangles such keyword attributes - in the `__init__` method. - - """ - __metaclass__ = PrivateFieldsMetaclass - - def __init__(self, *args, **kwargs): - new_kwargs = {} - prefix = '_' + self.__class__.__name__ + '__' - - field_names = set(f.name for f in self._meta.fields) - - for key, value in kwargs.iteritems(): - if prefix + key in field_names: - key = prefix + key - new_kwargs[key] = value - - _init_name_map_orig = self._meta.init_name_map - - def init_name_map(self): - cache = _init_name_map_orig() - - for key, value in cache.items(): - match = re.match(r'^_.+__(.*)', key) - if match is not None: - cache[match.group(1)] = value - return cache - init_name_map.patched = True - - if not getattr(self._meta.init_name_map, 'patched', False): - self._meta.init_name_map = type(_init_name_map_orig)(init_name_map, type(self._meta)) - - super(ModelWithPrivateFields, self).__init__(*args, **new_kwargs) - - class Meta: - abstract = True - diff --git a/src/django_fields/tests.py b/src/django_fields/tests.py index 971c994..32b0d20 100644 --- a/src/django_fields/tests.py +++ b/src/django_fields/tests.py @@ -7,6 +7,7 @@ import sys import unittest +import django from django.db import connection from django.db import models @@ -18,60 +19,108 @@ EncryptedEmailField, ) -from .models import ModelWithPrivateFields +if django.VERSION[1] > 9: + DJANGO_1_10 = True +else: + DJANGO_1_10 = False + +if sys.version_info[0] == 3: + PYTHON3 = True +else: + PYTHON3 = False class EncObject(models.Model): - max_password = 20 + max_password = 100 password = EncryptedCharField(max_length=max_password, null=True) + class Meta: + app_label = 'django_fields' + class EncDate(models.Model): important_date = EncryptedDateField() + class Meta: + app_label = 'django_fields' + class EncDateTime(models.Model): important_datetime = EncryptedDateTimeField() # important_datetime = EncryptedDateField() + class Meta: + app_label = 'django_fields' + class EncInt(models.Model): important_number = EncryptedIntField() + class Meta: + app_label = 'django_fields' + class EncLong(models.Model): important_number = EncryptedLongField() + class Meta: + app_label = 'django_fields' + class EncFloat(models.Model): important_number = EncryptedFloatField() + class Meta: + app_label = 'django_fields' + class PickleObject(models.Model): name = models.CharField(max_length=16) data = PickleField() + class Meta: + app_label = 'django_fields' + class EmailObject(models.Model): max_email = 255 - email = EncryptedEmailField() + email = EncryptedEmailField(max_length=max_email) + + class Meta: + app_label = 'django_fields' class USPhoneNumberField(models.Model): phone = EncryptedUSPhoneNumberField() + class Meta: + app_label = 'django_fields' + + class USSocialSecurityNumberField(models.Model): ssn = EncryptedUSSocialSecurityNumberField() + class Meta: + app_label = 'django_fields' + + class CipherEncObject(models.Model): max_password = 20 password = EncryptedCharField( max_length=max_password, - block_type = 'MODE_CBC') + block_type='MODE_CBC') + + class Meta: + app_label = 'django_fields' + class CipherEncDate(models.Model): important_date = EncryptedDateField(block_type='MODE_CBC') + class Meta: + app_label = 'django_fields' + + class EncryptTests(unittest.TestCase): def setUp(self): @@ -180,14 +229,14 @@ def test_none_value(self): def _get_encrypted_password(self, id): cursor = connection.cursor() cursor.execute("select password from django_fields_encobject where id = %s", [id,]) - passwords = map(lambda x: x[0], cursor.fetchall()) + passwords = list(map(lambda x: x[0], cursor.fetchall())) self.assertEqual(len(passwords), 1) # only one return passwords[0] def _get_encrypted_password_cipher(self, id): cursor = connection.cursor() cursor.execute("select password from django_fields_cipherencobject where id = %s", [id,]) - passwords = map(lambda x: x[0], cursor.fetchall()) + passwords = list(map(lambda x: x[0], cursor.fetchall())) self.assertEqual(len(passwords), 1) # only one return passwords[0] @@ -254,21 +303,21 @@ def test_date_encryption_w_cipher(self): def _get_encrypted_date(self, id): cursor = connection.cursor() cursor.execute("select important_date from django_fields_encdate where id = %s", [id,]) - important_dates = map(lambda x: x[0], cursor.fetchall()) + important_dates = list(map(lambda x: x[0], cursor.fetchall())) self.assertEqual(len(important_dates), 1) # only one return important_dates[0] def _get_encrypted_datetime(self, id): cursor = connection.cursor() cursor.execute("select important_datetime from django_fields_encdatetime where id = %s", [id,]) - important_datetimes = map(lambda x: x[0], cursor.fetchall()) + important_datetimes = list(map(lambda x: x[0], cursor.fetchall())) self.assertEqual(len(important_datetimes), 1) # only one return important_datetimes[0] def _get_encrypted_date_cipher(self, id): cursor = connection.cursor() cursor.execute("select important_date from django_fields_cipherencdate where id = %s", [id,]) - important_dates = map(lambda x: x[0], cursor.fetchall()) + important_dates = list(map(lambda x: x[0], cursor.fetchall())) self.assertEqual(len(important_dates), 1) # only one return important_dates[0] @@ -280,20 +329,37 @@ def setUp(self): EncFloat.objects.all().delete() def test_int_encryption(self): - self._test_number_encryption(EncInt, 'int', sys.maxint) + if PYTHON3 is True: + self._test_number_encryption(EncInt, 'int', sys.maxsize) + else: + self._test_number_encryption(EncInt, 'int', sys.maxint) def test_min_int_encryption(self): - self._test_number_encryption(EncInt, 'int', -sys.maxint - 1) + if PYTHON3 is True: + self._test_number_encryption(EncInt, 'int', -sys.maxsize - 1) + else: + self._test_number_encryption(EncInt, 'int', -sys.maxint - 1) def test_long_encryption(self): - self._test_number_encryption(EncLong, 'long', long(sys.maxint) * 100L) + if PYTHON3 is True: + self._test_number_encryption( + EncLong, 'long', int(sys.maxsize) * 100) + else: + self._test_number_encryption( + EncLong, 'long', long(sys.maxint) * long(100)) def test_float_encryption(self): - value = 123.456 + sys.maxint + if PYTHON3 is True: + value = 123.456 + sys.maxsize + else: + value = 123.456 + sys.maxint self._test_number_encryption(EncFloat, 'float', value) def test_one_third_float_encryption(self): - value = sys.maxint + (1.0 / 3.0) + if PYTHON3 is True: + value = sys.maxsize + (1.0 / 3.0) + else: + value = sys.maxint + (1.0 / 3.0) self._test_number_encryption(EncFloat, 'float', value) def _test_number_encryption(self, number_class, type_name, value): @@ -311,7 +377,7 @@ def _get_encrypted_number(self, type_name, id): cursor = connection.cursor() sql = "select important_number from django_fields_enc%s where id = %%s" % (type_name,) cursor.execute(sql, [id,]) - important_numbers = map(lambda x: x[0], cursor.fetchall()) + important_numbers = list(map(lambda x: x[0], cursor.fetchall())) self.assertEqual(len(important_numbers), 1) # only one return important_numbers[0] @@ -418,7 +484,7 @@ def test_minimum_padding(self): def _get_encrypted_email(self, id): cursor = connection.cursor() cursor.execute("select email from django_fields_emailobject where id = %s", [id,]) - emails = map(lambda x: x[0], cursor.fetchall()) + emails = list(map(lambda x: x[0], cursor.fetchall())) self.assertEqual(len(emails), 1) # only one return emails[0] @@ -435,44 +501,6 @@ def _get_two_emails(self, email_length): return enc_email_1, enc_email_2 -class TestModelWithPrivateFields(ModelWithPrivateFields): - """This model is for the unittests against ModelWithPrivateFields. - """ - __state = models.CharField(max_length=255, editable=False) - __state_changed_at = models.DateTimeField(editable=False, blank=True, null=True) - - def get_state(self): - return self.__state - - def set_state(self, value): - self.__state = value - self.__state_changed_at = datetime.datetime.now() - - state = property(get_state, set_state) - del get_state, set_state - - - -class PrivateFieldsTests(unittest.TestCase): - def test_private_fields(self): - obj1 = TestModelWithPrivateFields(state='blah') - - self.assert_(obj1._TestModelWithPrivateFields__state_changed_at is None) - obj1.save() - - obj2 = TestModelWithPrivateFields.objects.create(state='minor') - self.assert_(obj2._TestModelWithPrivateFields__state_changed_at is None) - - self.assertEqual(1, TestModelWithPrivateFields.objects.filter(state='blah').count()) - self.assertEqual(2, TestModelWithPrivateFields.objects.all().count()) - - obj1.state = 'blah2' # this has a side effect: - self.assert_(obj1._TestModelWithPrivateFields__state_changed_at is not None) - obj1.save() - - sql = unicode(TestModelWithPrivateFields.objects.filter(state='blah').query) - self.assert_('_TestModelWithPrivateFields__' not in sql, '_TestModelWithPrivateFields__ is in the "' + sql + '"') - class DatabaseSchemaTests(unittest.TestCase): def test_cipher_storage_length_versus_schema_length(self): @@ -490,7 +518,7 @@ def test_cipher_storage_length_versus_schema_length(self): def _get_raw_password_value(self, id): cursor = connection.cursor() cursor.execute("select password from django_fields_cipherencobject where id = %s", [id, ]) - passwords = map(lambda x: x[0], cursor.fetchall()) + passwords = list(map(lambda x: x[0], cursor.fetchall())) self.assertEqual(len(passwords), 1) # only one return passwords[0] @@ -505,10 +533,15 @@ def _get_password_field_column_width(self): password_field = [field for field in table_description if field[0] == 'password'] self.assertEqual(len(password_field), 1) password_field = password_field[0] + # if django < 1.10 # The second field contains the type; # this is something like u'varchar(78)' - raw_type = password_field[1] - matches = re.match('varchar\((\d+)\)', raw_type.lower()) - self.assertNotEqual(matches, None) - column_width = int(matches.groups()[0]) - return column_width + if DJANGO_1_10 is False: + raw_type = password_field[1] + matches = re.match('varchar\((\d+)\)', raw_type.lower()) + self.assertNotEqual(matches, None) + column_width = int(matches.groups()[0]) + return column_width + else: + raw_type = password_field.internal_size + return raw_type diff --git a/src/runtests.py b/src/runtests.py new file mode 100644 index 0000000..d580f79 --- /dev/null +++ b/src/runtests.py @@ -0,0 +1,18 @@ +import os +import sys +import django + +from django.conf import settings +from django.test.utils import get_runner + + +def runtests(): + os.environ['DJANGO_SETTINGS_MODULE'] = 'example.settings' + django.setup() + TestRunner = get_runner(settings) + test_runner = TestRunner() + failures = test_runner.run_tests(["django_fields.tests"]) + sys.exit(bool(failures)) + +if __name__ == '__main__': + runtests() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..94e6605 --- /dev/null +++ b/tox.ini @@ -0,0 +1,14 @@ +[tox] +envlist = + django18, + django19, + django110, + django{master} + +[testenv] +deps = + django18: Django==1.8.16 + django19: Django==1.9.11 + django110: Django==1.10.3 + djangomaster: https://github.com/django/django/archive/master.tar.gz +commands = python setup.py test