diff --git a/.travis.yml b/.travis.yml index 6c23f65..cfaf644 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,26 @@ sudo: false language: python -python: - - '2.7' +services: + - mysql install: - pip install tox -env: - - TOX_ENV=py33 - - TOX_ENV=py34 +matrix: + include: + - stage: test + python: 3.3 + env: TOX_ENV=py33 + - stage: test + python: 3.4 + env: TOX_ENV=py34 + - stage: test + python: 3.5 + env: TOX_ENV=py35 + - stage: test + python: 3.6 + env: TOX_ENV=py36 script: - tox -e $TOX_ENV diff --git a/README.rst b/README.rst index 5253314..ba096e8 100644 --- a/README.rst +++ b/README.rst @@ -9,8 +9,7 @@ SQLAlchemy-filters Filtering --------- -Assuming that we have a SQLAlchemy `query` that only contains a single -model: +Assuming that we have a SQLAlchemy `query` object: .. code-block:: python @@ -33,7 +32,7 @@ model: # ... - query = self.session.query(Foo) + query = session.query(Foo) Then we can apply filters to that ``query`` object (multiple times): @@ -51,12 +50,36 @@ Then we can apply filters to that ``query`` object (multiple times): result = filtered_query.all() +It is also possible to filter queries that contain multiple models, including joins: + +.. code-block:: python + + class Bar(Base): + + __tablename__ = 'bar' + foo_id = Column(Integer, ForeignKey('foo.id')) + + +.. code-block:: python + + query = session.query(Foo).join(Bar) + + filters = [ + {'model': 'Foo', field': 'name', 'op': '==', 'value': 'name_1'}, + {'model': 'Bar', field': 'count', 'op': '>=', 'value': 5}, + ] + filtered_query = apply_filters(query, filters) + + result = filtered_query.all() + +You must specify the `model` key in each filter if the query is against more than one model. + Note that we can also apply filters to queries defined by fields or functions: .. code-block:: python - query_alt_1 = self.session.query(Foo.id, Foo.name) - query_alt_2 = self.session.query(func.count(Foo.id)) + query_alt_1 = session.query(Foo.id, Foo.name) + query_alt_2 = session.query(func.count(Foo.id)) Sort @@ -69,8 +92,8 @@ Sort # `query` should be a SQLAlchemy query object order_by = [ - {'field': 'name', 'direction': 'asc'}, - {'field': 'id', 'direction': 'desc'}, + {'model': 'Foo', field': 'name', 'direction': 'asc'}, + {'model': 'Bar', field': 'id', 'direction': 'desc'}, ] sorted_query = apply_sort(query, order_by) @@ -106,12 +129,14 @@ following format: .. code-block:: python filters = [ - {'field': 'field_name', 'op': '==', 'value': 'field_value'}, - {'field': 'field_2_name', 'op': '!=', 'value': 'field_2_value'}, + {'model': 'model_name', 'field': 'field_name', 'op': '==', 'value': 'field_value'}, + {{'model': 'model_name', 'field': 'field_2_name', 'op': '!=', 'value': 'field_2_value'}, # ... ] -Optionally, if there is only one filter, the containing list may be omitted: +The `model` key is optional if the query being filtered only applies to one model. + +If there is only one filter, the containing list may be omitted: .. code-block:: python @@ -171,14 +196,17 @@ applied sequentially: .. code-block:: python order_by = [ - {'field': 'name', 'direction': 'asc'}, - {'field': 'id', 'direction': 'desc'}, + {'model': 'Foo', 'field': 'name', 'direction': 'asc'}, + {'model': 'Bar', field': 'id', 'direction': 'desc'}, # ... ] Where ``field`` is the name of the field that will be sorted using the provided ``direction``. +The `model` key is optional if the query being sorted only applies to one model. + + Running tests ------------- diff --git a/setup.py b/setup.py index 36a5142..3eeed32 100644 --- a/setup.py +++ b/setup.py @@ -31,24 +31,20 @@ 'sqlalchemy-utils==0.32.12', ], 'mysql': [ - 'mysql-connector-python==2.1.5', + 'mysql-connector-python-rf==2.1.3', ] }, - dependency_links=[ - 'https://cdn.mysql.com/Downloads/Connector-Python' - '/mysql-connector-python-2.1.5.zip' - ], zip_safe=True, license='Apache License, Version 2.0', classifiers=[ "Programming Language :: Python", "Operating System :: POSIX", "Operating System :: MacOS :: MacOS X", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", "Intended Audience :: Developers", diff --git a/sqlalchemy_filters/filters.py b/sqlalchemy_filters/filters.py index a1b3997..a6c28f9 100644 --- a/sqlalchemy_filters/filters.py +++ b/sqlalchemy_filters/filters.py @@ -69,7 +69,23 @@ def __init__(self, filter_, models): 'Filter `{}` should be a dictionary.'.format(filter_) ) - self.field = Field(models, field_name) + model_name = filter_.get('model') + if model_name is not None: + models = [v for (k, v) in models.items() if k == model_name] + if not models: + raise BadFilterFormat( + 'The query does not contain model `{}`.'.format(model_name) + ) + model = models[0] + else: + if len(models) == 1: + model = list(models.values())[0] + else: + raise BadFilterFormat( + "Ambiguous filter. Please specify a model." + ) + + self.field = Field(model, field_name) self.operator = Operator(filter_.get('op')) self.value = filter_.get('value') self.value_present = True if 'value' in filter_ else False diff --git a/sqlalchemy_filters/models.py b/sqlalchemy_filters/models.py index ff72a22..24e080a 100644 --- a/sqlalchemy_filters/models.py +++ b/sqlalchemy_filters/models.py @@ -1,22 +1,14 @@ from sqlalchemy.inspection import inspect -from .exceptions import FieldNotFound, BadQuery +from .exceptions import FieldNotFound class Field(object): - def __init__(self, models, field_name): - # TODO: remove this check once we start supporing multiple models - if len(models) > 1: - raise BadQuery('The query should contain only one model.') - - self.model = self._get_model(models) + def __init__(self, model, field_name): + self.model = model self.field_name = field_name - def _get_model(self, models): - # TODO: add model_name argument once we start supporing multiple models - return [v for (k, v) in models.items()][0] # first (and only) model - def get_sqlalchemy_field(self): if self.field_name not in inspect(self.model).columns.keys(): raise FieldNotFound( @@ -36,7 +28,8 @@ def get_query_models(query): :returns: A dictionary with all the models included in the query. """ + models = [col_desc['entity'] for col_desc in query.column_descriptions] + models.extend(mapper.class_ for mapper in query._join_entities) return { - col_desc['entity'].__name__: col_desc['entity'] - for col_desc in query.column_descriptions + model.__name__: model for model in models } diff --git a/sqlalchemy_filters/sorting.py b/sqlalchemy_filters/sorting.py index c0e16e1..2931751 100644 --- a/sqlalchemy_filters/sorting.py +++ b/sqlalchemy_filters/sorting.py @@ -26,7 +26,23 @@ def __init__(self, sort, models): if direction not in [SORT_ASCENDING, SORT_DESCENDING]: raise BadSortFormat('Direction `{}` not valid.'.format(direction)) - self.field = Field(models, field_name) + model_name = sort.get('model') + if model_name is not None: + models = [v for (k, v) in models.items() if k == model_name] + if not models: + raise BadSortFormat( + 'The query does not contain model `{}`.'.format(model_name) + ) + model = models[0] + else: + if len(models) == 1: + model = list(models.values())[0] + else: + raise BadSortFormat( + "Ambiguous sort. Please specify a model.".format() + ) + + self.field = Field(model, field_name) self.direction = direction def format_for_sqlalchemy(self): diff --git a/test/interface/test_filters.py b/test/interface/test_filters.py index 7324bbf..51addeb 100644 --- a/test/interface/test_filters.py +++ b/test/interface/test_filters.py @@ -11,7 +11,42 @@ from test.models import Bar, Qux -class TestProvidedModels(object): +@pytest.fixture +def multiple_bars_inserted(session): + bar_1 = Bar(id=1, name='name_1', count=5) + bar_2 = Bar(id=2, name='name_2', count=10) + bar_3 = Bar(id=3, name='name_1', count=None) + bar_4 = Bar(id=4, name='name_4', count=15) + session.add_all([bar_1, bar_2, bar_3, bar_4]) + session.commit() + + +@pytest.fixture +def multiple_quxs_inserted(session): + qux_1 = Qux( + id=1, name='name_1', count=5, + created_at=datetime.date(2016, 7, 12), + execution_time=datetime.datetime(2016, 7, 12, 1, 5, 9) + ) + qux_2 = Qux( + id=2, name='name_2', count=10, + created_at=datetime.date(2016, 7, 13), + execution_time=datetime.datetime(2016, 7, 13, 2, 5, 9) + ) + qux_3 = Qux( + id=3, name='name_1', count=None, + created_at=None, execution_time=None + ) + qux_4 = Qux( + id=4, name='name_4', count=15, + created_at=datetime.date(2016, 7, 14), + execution_time=datetime.datetime(2016, 7, 14, 3, 5, 9) + ) + session.add_all([qux_1, qux_2, qux_3, qux_4]) + session.commit() + + +class TestProvidedModels: def test_query_with_no_models(self, session): query = session.query() @@ -22,33 +57,65 @@ def test_query_with_no_models(self, session): assert 'The query does not contain any models.' == err.value.args[0] - # TODO: replace this test once we support multiple models - def test_multiple_models(self, session): - query = session.query(Bar, Qux) - filters = [{'field': 'name', 'op': '==', 'value': 'name_1'}] + @pytest.mark.usefixtures('multiple_bars_inserted') + def test_query_with_named_model(self, session): + query = session.query(Bar) + filters = [ + {'model': 'Bar', 'field': 'name', 'op': '==', 'value': 'name_1'} + ] - with pytest.raises(BadQuery) as err: + filtered_query = apply_filters(query, filters) + result = filtered_query.all() + + assert len(result) == 2 + assert result[0].id == 1 + assert result[1].id == 3 + + def test_query_with_missing_named_model(self, session): + query = session.query(Bar) + filters = [ + {'model': 'Buz', 'field': 'name', 'op': '==', 'value': 'name_1'} + ] + + with pytest.raises(BadFilterFormat) as err: apply_filters(query, filters) - expected_error = ( - 'The query should contain only one model.' - ) - assert expected_error == err.value.args[0] + assert 'The query does not contain model `Buz`.' == err.value.args[0] + + @pytest.mark.usefixtures('multiple_bars_inserted') + @pytest.mark.usefixtures('multiple_quxs_inserted') + def test_multiple_models(self, session): + query = session.query(Bar, Qux) + filters = [ + {'model': 'Bar', 'field': 'name', 'op': '==', 'value': 'name_1'}, + {'model': 'Qux', 'field': 'name', 'op': '==', 'value': 'name_1'}, + ] + + filtered_query = apply_filters(query, filters) + result = filtered_query.all() + assert len(result) == 4 + bars, quxs = zip(*result) + assert set(map(type, bars)) == {Bar} + assert {bar.id for bar in bars} == {1, 3} + assert {bar.name for bar in bars} == {"name_1"} + assert set(map(type, quxs)) == {Qux} + assert {qux.id for qux in quxs} == {1, 3} + assert {qux.name for qux in quxs} == {"name_1"} + + def test_multiple_models_ambiquous_query(self, session): + query = session.query(Bar, Qux) + filters = [ + {'field': 'name', 'op': '==', 'value': 'name_1'} + ] -class TestFiltersMixin(object): + with pytest.raises(BadFilterFormat) as err: + apply_filters(query, filters) - @pytest.fixture - def multiple_bars_inserted(self, session): - bar_1 = Bar(id=1, name='name_1', count=5) - bar_2 = Bar(id=2, name='name_2', count=10) - bar_3 = Bar(id=3, name='name_1', count=None) - bar_4 = Bar(id=4, name='name_4', count=15) - session.add_all([bar_1, bar_2, bar_3, bar_4]) - session.commit() + assert 'Ambiguous filter. Please specify a model.' == err.value.args[0] -class TestProvidedFilters(TestFiltersMixin): +class TestProvidedFilters: def test_no_filters_provided(self, session): query = session.query(Bar) @@ -144,7 +211,7 @@ def test_invalid_field_but_valid_model_attribute(self, session, attr_name): assert expected_error == err.value.args[0] -class TestApplyIsNullFilter(TestFiltersMixin): +class TestApplyIsNullFilter: @pytest.mark.usefixtures('multiple_bars_inserted') def test_filter_field_with_null_values(self, session): @@ -168,7 +235,7 @@ def test_filter_field_with_no_null_values(self, session): assert len(result) == 0 -class TestApplyIsNotNullFilter(TestFiltersMixin): +class TestApplyIsNotNullFilter: @pytest.mark.usefixtures('multiple_bars_inserted') def test_filter_field_with_null_values(self, session): @@ -198,7 +265,7 @@ def test_filter_field_with_no_null_values(self, session): assert result[3].id == 4 -class TestApplyFiltersMultipleTimes(TestFiltersMixin): +class TestApplyFiltersMultipleTimes: @pytest.mark.usefixtures('multiple_bars_inserted') def test_concatenate_queries(self, session): @@ -224,7 +291,7 @@ def test_concatenate_queries(self, session): assert result[0].name == 'name_1' -class TestApplyFilterWithoutList(TestFiltersMixin): +class TestApplyFilterWithoutList: @pytest.mark.usefixtures('multiple_bars_inserted') def test_a_single_dict_can_be_supplied_as_filters(self, session): @@ -241,7 +308,7 @@ def test_a_single_dict_can_be_supplied_as_filters(self, session): assert result[1].name == 'name_1' -class TestApplyFilterOnFieldBasedQuery(TestFiltersMixin): +class TestApplyFilterOnFieldBasedQuery: @pytest.mark.usefixtures('multiple_bars_inserted') def test_apply_filter_on_single_field_query(self, session): @@ -267,7 +334,7 @@ def test_apply_filter_on_aggregate_query(self, session): assert result[0] == (2,) -class TestApplyEqualToFilter(TestFiltersMixin): +class TestApplyEqualToFilter: @pytest.mark.parametrize('operator', ['==', 'eq']) @pytest.mark.usefixtures('multiple_bars_inserted') @@ -310,7 +377,7 @@ def test_multiple_filters_applied_to_a_single_model( assert result[0].name == 'name_1' -class TestApplyNotEqualToFilter(TestFiltersMixin): +class TestApplyNotEqualToFilter: @pytest.mark.parametrize('operator', ['!=', 'ne']) @pytest.mark.usefixtures('multiple_bars_inserted') @@ -348,7 +415,7 @@ def test_multiple_filters_applied_to_a_single_model( assert result[1].name == 'name_4' -class TestApplyGreaterThanFilter(TestFiltersMixin): +class TestApplyGreaterThanFilter: @pytest.mark.parametrize('operator', ['>', 'gt']) @pytest.mark.usefixtures('multiple_bars_inserted') @@ -381,7 +448,7 @@ def test_multiple_filters_applied_to_a_single_model( assert result[0].id == 4 -class TestApplyLessThanFilter(TestFiltersMixin): +class TestApplyLessThanFilter: @pytest.mark.parametrize('operator', ['<', 'lt']) @pytest.mark.usefixtures('multiple_bars_inserted') @@ -412,7 +479,7 @@ def test_multiple_filters_applied_to_a_single_model( assert len(result) == 0 -class TestApplyGreaterOrEqualThanFilter(TestFiltersMixin): +class TestApplyGreaterOrEqualThanFilter: @pytest.mark.parametrize('operator', ['>=', 'ge']) @pytest.mark.usefixtures('multiple_bars_inserted') @@ -446,7 +513,7 @@ def test_multiple_filters_applied_to_a_single_model( assert result[0].id == 4 -class TestApplyLessOrEqualThanFilter(TestFiltersMixin): +class TestApplyLessOrEqualThanFilter: @pytest.mark.parametrize('operator', ['<=', 'le']) @pytest.mark.usefixtures('multiple_bars_inserted') @@ -480,7 +547,7 @@ def test_multiple_filters_applied_to_a_single_model( assert result[0].id == 1 -class TestApplyLikeFilter(TestFiltersMixin): +class TestApplyLikeFilter: @pytest.mark.usefixtures('multiple_bars_inserted') def test_one_filter_applied_to_a_single_model(self, session): @@ -495,7 +562,7 @@ def test_one_filter_applied_to_a_single_model(self, session): assert result[1].id == 3 -class TestApplyInFilter(TestFiltersMixin): +class TestApplyInFilter: @pytest.mark.usefixtures('multiple_bars_inserted') def test_field_not_in_value_list(self, session): @@ -519,7 +586,7 @@ def test_field_in_value_list(self, session): assert result[0].id == 4 -class TestApplyNotInFilter(TestFiltersMixin): +class TestApplyNotInFilter: @pytest.mark.usefixtures('multiple_bars_inserted') def test_field_not_in_value_list(self, session): @@ -547,34 +614,7 @@ def test_field_in_value_list(self, session): assert result[1].id == 2 -class TestFilterDatesMixin(object): - - @pytest.fixture - def multiple_quxs_inserted(self, session): - qux_1 = Qux( - id=1, name='name_1', count=5, - created_at=datetime.date(2016, 7, 12), - execution_time=datetime.datetime(2016, 7, 12, 1, 5, 9) - ) - qux_2 = Qux( - id=2, name='name_2', count=10, - created_at=datetime.date(2016, 7, 13), - execution_time=datetime.datetime(2016, 7, 13, 2, 5, 9) - ) - qux_3 = Qux( - id=3, name='name_1', count=None, - created_at=None, execution_time=None - ) - qux_4 = Qux( - id=4, name='name_4', count=15, - created_at=datetime.date(2016, 7, 14), - execution_time=datetime.datetime(2016, 7, 14, 3, 5, 9) - ) - session.add_all([qux_1, qux_2, qux_3, qux_4]) - session.commit() - - -class TestDateFields(TestFilterDatesMixin): +class TestDateFields: @pytest.mark.parametrize( 'value', @@ -637,7 +677,7 @@ def test_null_date(self, session): assert result[0].created_at is None -class TestDateTimeFields(TestFilterDatesMixin): +class TestDateTimeFields: @pytest.mark.parametrize( 'value', @@ -708,7 +748,7 @@ def test_null_datetime(self, session): assert result[0].execution_time is None -class TestApplyBooleanFunctions(TestFiltersMixin): +class TestApplyBooleanFunctions: @pytest.mark.usefixtures('multiple_bars_inserted') def test_or(self, session): diff --git a/test/interface/test_models.py b/test/interface/test_models.py index e90a3e5..cdde6dd 100644 --- a/test/interface/test_models.py +++ b/test/interface/test_models.py @@ -53,3 +53,17 @@ def test_query_with_aggregate_func(self, session): entities = get_query_models(query) assert {'Foo': Foo} == entities + + def test_query_with_join(self, session): + query = session.query(Foo).join(Bar) + + entities = get_query_models(query) + + assert {'Foo': Foo, 'Bar': Bar} == entities + + def test_query_with_multiple_joins(self, session): + query = session.query(Foo).join(Bar).join(Qux, Bar.id == Qux.id) + + entities = get_query_models(query) + + assert {'Foo': Foo, 'Bar': Bar, 'Qux': Qux} == entities diff --git a/test/interface/test_sorting.py b/test/interface/test_sorting.py index 07e36e7..d435846 100644 --- a/test/interface/test_sorting.py +++ b/test/interface/test_sorting.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +import datetime + import pytest from sqlalchemy_filters.exceptions import ( @@ -10,6 +12,22 @@ from test.models import Bar, Qux +@pytest.fixture +def multiple_bars_inserted(session): + bar_1 = Bar(id=1, name='name_1', count=5) + bar_2 = Bar(id=2, name='name_2', count=10) + bar_3 = Bar(id=3, name='name_1', count=None) + bar_4 = Bar(id=4, name='name_4', count=12) + bar_5 = Bar(id=5, name='name_1', count=2) + bar_6 = Bar(id=6, name='name_4', count=15) + bar_7 = Bar(id=7, name='name_1', count=2) + bar_8 = Bar(id=8, name='name_5', count=1) + session.add_all( + [bar_1, bar_2, bar_3, bar_4, bar_5, bar_6, bar_7, bar_8] + ) + session.commit() + + class TestProvidedModels(object): def test_query_with_no_models(self, session): @@ -21,16 +39,88 @@ def test_query_with_no_models(self, session): assert 'The query does not contain any models.' == error_value(err) - # TODO: replace this test once we support multiple models + @pytest.mark.usefixtures('multiple_bars_inserted') + def test_query_with_named_model(self, session): + query = session.query(Bar) + order_by = [{'model': 'Bar', 'field': 'name', 'direction': 'asc'}] + + sorted_query = apply_sort(query, order_by) + result = sorted_query.all() + + assert len(result) == 8 + assert result[0].id == 1 + assert result[1].id == 3 + assert result[2].id == 5 + assert result[3].id == 7 + assert result[4].id == 2 + assert result[5].id == 4 + assert result[6].id == 6 + assert result[7].id == 8 + + def test_query_with_missing_named_model(self, session): + query = session.query(Bar) + order_by = [{'model': 'Buz', 'field': 'name', 'direction': 'asc'}] + + with pytest.raises(BadSortFormat) as err: + apply_sort(query, order_by) + + assert 'The query does not contain model `Buz`.' == err.value.args[0] + def test_multiple_models(self, session): + + bar_1 = Bar(id=1, name='name_1', count=5) + bar_2 = Bar(id=2, name='name_2', count=10) + bar_3 = Bar(id=3, name='name_1', count=None) + bar_4 = Bar(id=4, name='name_1', count=12) + + qux_1 = Qux( + id=1, name='name_1', count=5, + created_at=datetime.date(2016, 7, 12), + execution_time=datetime.datetime(2016, 7, 12, 1, 5, 9) + ) + qux_2 = Qux( + id=2, name='name_2', count=10, + created_at=datetime.date(2016, 7, 13), + execution_time=datetime.datetime(2016, 7, 13, 2, 5, 9) + ) + qux_3 = Qux( + id=3, name='name_1', count=None, + created_at=None, execution_time=None + ) + qux_4 = Qux( + id=4, name='name_1', count=15, + created_at=datetime.date(2016, 7, 14), + execution_time=datetime.datetime(2016, 7, 14, 3, 5, 9) + ) + + session.add_all( + [bar_1, bar_2, bar_3, bar_4, qux_1, qux_2, qux_3, qux_4] + ) + session.commit() + + query = session.query(Bar).join(Qux, Bar.id == Qux.id) + order_by = [ + {'model': 'Bar', 'field': 'name', 'direction': 'asc'}, + {'model': 'Qux', 'field': 'count', 'direction': 'asc'} + ] + + sorted_query = apply_sort(query, order_by) + result = sorted_query.all() + + assert len(result) == 4 + assert result[0].id == 3 + assert result[1].id == 1 + assert result[2].id == 4 + assert result[3].id == 2 + + def test_multiple_models_ambiquous_query(self, session): query = session.query(Bar, Qux) order_by = [{'field': 'name', 'direction': 'asc'}] - with pytest.raises(BadQuery) as err: + with pytest.raises(BadSortFormat) as err: apply_sort(query, order_by) - expected_error = 'The query should contain only one model.' - assert expected_error == error_value(err) + assert 'Ambiguous sort. Please specify a model.' == err.value.args[0] class TestSortNotApplied(object): @@ -99,21 +189,6 @@ def test_invalid_direction(self, session): class TestSortApplied(object): - @pytest.fixture - def multiple_bars_inserted(self, session): - bar_1 = Bar(id=1, name='name_1', count=5) - bar_2 = Bar(id=2, name='name_2', count=10) - bar_3 = Bar(id=3, name='name_1', count=None) - bar_4 = Bar(id=4, name='name_4', count=12) - bar_5 = Bar(id=5, name='name_1', count=2) - bar_6 = Bar(id=6, name='name_4', count=15) - bar_7 = Bar(id=7, name='name_1', count=2) - bar_8 = Bar(id=8, name='name_5', count=1) - session.add_all( - [bar_1, bar_2, bar_3, bar_4, bar_5, bar_6, bar_7, bar_8] - ) - session.commit() - @pytest.mark.usefixtures('multiple_bars_inserted') def test_single_sort_field_asc(self, session): query = session.query(Bar) diff --git a/tox.ini b/tox.ini index 45145b3..c39ada2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,10 @@ [tox] -envlist = {py33,py34} +envlist = {py33,py34,py35,py36} skipdist=True [testenv] whitelist_externals = make commands = - pip install -U --editable ".[dev, mysql]" --process-dependency-links + pip install -U --editable ".[dev, mysql]" make coverage ARGS='-x -vv'