From 76813847344c3740b557ecc923e058d4b69291c2 Mon Sep 17 00:00:00 2001 From: Guillaume Andreu Sabater Date: Fri, 22 Jun 2018 02:12:42 +0200 Subject: [PATCH 1/3] added django>=2.0 compat --- .../dts_test_app/migrations/0001_initial.py | 10 +- dts_test_project/dts_test_app/models.py | 7 +- setup.py | 8 +- .../management/commands/__init__.py | 23 ++- .../management/commands/tenant_command.py | 21 +- tenant_schemas/postgresql_backend/base.py | 5 +- .../postgresql_backend/introspection.py | 8 +- tenant_schemas/template_loaders.py | 187 ++++++------------ tenant_schemas/tests/__init__.py | 6 - .../tests/multitenant/localhost/hello.html | 1 + .../tests/template_loader/__init__.py | 1 - .../test_cached_template_loader.py | 31 --- .../templates/hello.html | 0 tenant_schemas/tests/test_template_loader.py | 61 ++++++ tenant_schemas/tests/test_tenants.py | 31 ++- tenant_schemas/urlresolvers.py | 2 +- tox.ini | 13 +- 17 files changed, 205 insertions(+), 210 deletions(-) create mode 100644 tenant_schemas/tests/multitenant/localhost/hello.html delete mode 100644 tenant_schemas/tests/template_loader/__init__.py delete mode 100755 tenant_schemas/tests/template_loader/test_cached_template_loader.py rename tenant_schemas/tests/{template_loader => }/templates/hello.html (100%) create mode 100755 tenant_schemas/tests/test_template_loader.py diff --git a/dts_test_project/dts_test_app/migrations/0001_initial.py b/dts_test_project/dts_test_app/migrations/0001_initial.py index f7258523..aa0a57c6 100644 --- a/dts_test_project/dts_test_app/migrations/0001_initial.py +++ b/dts_test_project/dts_test_app/migrations/0001_initial.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import migrations, models +import django.db.models.deletion from django.conf import settings @@ -25,11 +26,8 @@ class Migration(migrations.Migration): migrations.CreateModel( name='ModelWithFkToPublicUser', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], - options={ - }, - bases=(models.Model,), ), ] diff --git a/dts_test_project/dts_test_app/models.py b/dts_test_project/dts_test_app/models.py index 0baed108..f8a44137 100644 --- a/dts_test_project/dts_test_app/models.py +++ b/dts_test_project/dts_test_app/models.py @@ -13,4 +13,9 @@ def __unicode__(self): class ModelWithFkToPublicUser(models.Model): - user = models.ForeignKey(User) + """Test model with foreign key.""" + + user = models.ForeignKey( + User, + on_delete=models.CASCADE + ) diff --git a/setup.py b/setup.py index 6b8613af..0176ae52 100755 --- a/setup.py +++ b/setup.py @@ -32,9 +32,9 @@ classifiers=[ 'License :: OSI Approved :: MIT License', 'Framework :: Django', - 'Framework :: Django :: 1.8', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', + 'Framework :: Django :: 1.11', + 'Framework :: Django :: 2.0', + 'Framework :: Django :: 2.1', 'Programming Language :: Python', "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.5", @@ -42,7 +42,7 @@ "Topic :: Software Development :: Libraries", ], install_requires=[ - 'Django >= 1.8.0', + 'Django >= 1.11.0', 'psycopg2', ], zip_safe=False, diff --git a/tenant_schemas/management/commands/__init__.py b/tenant_schemas/management/commands/__init__.py index c3c28edd..3fa2f766 100644 --- a/tenant_schemas/management/commands/__init__.py +++ b/tenant_schemas/management/commands/__init__.py @@ -86,27 +86,31 @@ class InteractiveTenantOption(object): def add_arguments(self, parser): parser.add_argument("-s", "--schema", dest="schema_name", help="specify tenant schema") - def get_tenant_from_options_or_interactive(self, **options): + def get_tenant_from_options_or_interactive(self, schema_name=None): TenantModel = get_tenant_model() - all_tenants = TenantModel.objects.all() + values = TenantModel.objects.values_list( + "schema_name", "domain_url" + ) - if not all_tenants: + if not values: raise CommandError("""There are no tenants in the system. To learn how create a tenant, see: https://django-tenant-schemas.readthedocs.io/en/latest/use.html#creating-a-tenant""") - if options.get('schema_name'): - tenant_schema = options['schema_name'] + if schema_name is not None: + tenant_schema = schema_name else: while True: tenant_schema = input("Enter Tenant Schema ('?' to list schemas): ") if tenant_schema == '?': - print('\n'.join(["%s - %s" % (t.schema_name, t.domain_url,) for t in all_tenants])) + print('\n'.join(["%s - %s" % item for item in values])) else: break - if tenant_schema not in [t.schema_name for t in all_tenants]: - raise CommandError("Invalid tenant schema, '%s'" % (tenant_schema,)) + if tenant_schema not in [schema_name for schema_name, _ in values]: + raise CommandError( + "Invalid tenant schema, '%s'" % (tenant_schema,) + ) return TenantModel.objects.get(schema_name=tenant_schema) @@ -130,7 +134,8 @@ def add_arguments(self, parser): self.command_instance.add_arguments(parser) def handle(self, *args, **options): - tenant = self.get_tenant_from_options_or_interactive(**options) + schema_name = options.pop("schema_name", None) + tenant = self.get_tenant_from_options_or_interactive(schema_name) connection.set_tenant(tenant) self.command_instance.execute(*args, **options) diff --git a/tenant_schemas/management/commands/tenant_command.py b/tenant_schemas/management/commands/tenant_command.py index 8b441092..0c71d6c5 100644 --- a/tenant_schemas/management/commands/tenant_command.py +++ b/tenant_schemas/management/commands/tenant_command.py @@ -8,6 +8,13 @@ class Command(InteractiveTenantOption, BaseCommand): help = "Wrapper around django commands for use with an individual tenant" + def add_arguments(self, parser): + super(Command, self).add_arguments(parser) + + parser.add_argument("original_command_name") + parser.add_argument("original_command_args", nargs="*") + parser.add_argument("--original_command_options", nargs="*") + def run_from_argv(self, argv): """ Changes the option_list to use the options from the wrapped command. @@ -26,8 +33,9 @@ def run_from_argv(self, argv): else: klass = load_command_class(app_name, argv[2]) - # Ugly, but works. Delete tenant_command from the argv, parse the schema manually - # and forward the rest of the arguments to the actual command being wrapped. + # Ugly, but works. Delete tenant_command from the argv, parse the + # schema manually and forward the rest of the arguments to the actual + # command being wrapped. del argv[1] schema_parser = argparse.ArgumentParser() schema_parser.add_argument("-s", "--schema", dest="schema_name", help="specify tenant schema") @@ -38,6 +46,11 @@ def run_from_argv(self, argv): klass.run_from_argv(args) def handle(self, *args, **options): - tenant = self.get_tenant_from_options_or_interactive(**options) + schema_name = options.pop("schema_name", None) + tenant = self.get_tenant_from_options_or_interactive(schema_name) connection.set_tenant(tenant) - call_command(*args, **options) + + command_name = options.pop("original_command_name") + args += tuple(options.pop("original_command_args")) + options.update(options.pop("original_command_options", {})) + call_command(command_name, *args, **options) diff --git a/tenant_schemas/postgresql_backend/base.py b/tenant_schemas/postgresql_backend/base.py index 212d3eae..b4963470 100644 --- a/tenant_schemas/postgresql_backend/base.py +++ b/tenant_schemas/postgresql_backend/base.py @@ -10,10 +10,7 @@ from tenant_schemas.utils import get_public_schema_name, get_limit_set_calls from tenant_schemas.postgresql_backend.introspection import DatabaseSchemaIntrospection - -ORIGINAL_BACKEND = getattr(settings, 'ORIGINAL_BACKEND', 'django.db.backends.postgresql_psycopg2') -# Django 1.9+ takes care to rename the default backend to 'django.db.backends.postgresql' -original_backend = django.db.utils.load_backend(ORIGINAL_BACKEND) +original_backend = django.db.utils.load_backend('django.db.backends.postgresql') EXTRA_SEARCH_PATHS = getattr(settings, 'PG_EXTRA_SEARCH_PATHS', []) diff --git a/tenant_schemas/postgresql_backend/introspection.py b/tenant_schemas/postgresql_backend/introspection.py index 7ff2cca5..9887322a 100644 --- a/tenant_schemas/postgresql_backend/introspection.py +++ b/tenant_schemas/postgresql_backend/introspection.py @@ -5,11 +5,7 @@ from django.db.backends.base.introspection import ( BaseDatabaseIntrospection, FieldInfo, TableInfo, ) -try: - # Django >= 1.11 - from django.db.models.indexes import Index -except ImportError: - Index = None +from django.db.models.indexes import Index from django.utils.encoding import force_text fields = FieldInfo._fields @@ -310,7 +306,7 @@ def get_constraints(self, cursor, table_name): "foreign_key": None, "check": False, "index": True, - "type": Index.suffix if type_ == 'btree' and Index else type_, + "type": Index.suffix if type_ == 'btree' else type_, "definition": definition, "options": options, } diff --git a/tenant_schemas/template_loaders.py b/tenant_schemas/template_loaders.py index 6224a268..fd3e247b 100644 --- a/tenant_schemas/template_loaders.py +++ b/tenant_schemas/template_loaders.py @@ -3,147 +3,90 @@ multi-tenant setting """ -import hashlib - from django.conf import settings -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ( + ImproperlyConfigured, SuspiciousFileOperation) from django.db import connection -from django.template import TemplateDoesNotExist -from django.template.base import Template -from django.template.loaders.base import Loader as BaseLoader +from django.template import Origin +from django.template.loaders.cached import Loader as BaseCachedLoader +from django.template.loaders.filesystem import ( + Loader as BaseFilesystemLoader) from django.utils._os import safe_join -from django.utils.encoding import force_bytes +from django.utils.encoding import force_text from tenant_schemas.postgresql_backend.base import FakeTenant -try: - from django.template import Origin - def make_origin(engine, name, loader, template_name, dirs): - return Origin(name=name, template_name=template_name, loader=loader) +class CachedLoader(BaseCachedLoader): + """Overide django's cached loader.""" -except ImportError: # Django 1.8 backwards compatibility - def make_origin(engine, name, loader, template_name, dirs): - return engine.make_origin(name, loader, template_name, dirs) + def cache_key(self, template_name, template_dirs=None, skip=None): + """Override Django's method, injecting tenant pk when available.""" + dirs_prefix = '' + skip_prefix = '' + if skip: + matching = [ + origin.name + for origin in skip + if origin.template_name == template_name + ] + if matching: + skip_prefix = self.generate_hash(matching) -class CachedLoader(BaseLoader): - is_usable = True + if template_dirs: + dirs_prefix = self.generate_hash(template_dirs) - def __init__(self, engine, loaders): - self.template_cache = {} - self.find_template_cache = {} - self.loaders = engine.get_template_loaders(loaders) - super(CachedLoader, self).__init__(engine) + values = [ + s + for s in (force_text(template_name), skip_prefix, dirs_prefix) + if s + ] - @staticmethod - def cache_key(template_name, template_dirs): - if connection.tenant and template_dirs: - return '-'.join([str(connection.tenant.pk), template_name, - hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) - if template_dirs: - # If template directories were specified, use a hash to differentiate - return '-'.join([template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) - else: - return template_name - - def find_template(self, name, dirs=None): - """ - Helper method. Lookup the template :param name: in all the configured loaders - """ - key = self.cache_key(name, dirs) - try: - result = self.find_template_cache[key] - except KeyError: - result = None - for loader in self.loaders: - try: - template, display_name = loader(name, dirs) - except TemplateDoesNotExist: - pass - else: - origin = make_origin(self.engine, display_name, loader, name, dirs) - result = template, origin - break - self.find_template_cache[key] = result - if result: - return result - else: - self.template_cache[key] = TemplateDoesNotExist - raise TemplateDoesNotExist(name) - - def load_template(self, template_name, template_dirs=None): - key = self.cache_key(template_name, template_dirs) - template_tuple = self.template_cache.get(key) - # A cached previous failure: - if template_tuple is TemplateDoesNotExist: - raise TemplateDoesNotExist - elif template_tuple is None: - template, origin = self.find_template(template_name, template_dirs) - if not hasattr(template, 'render'): - try: - template = Template(template, origin, template_name, self.engine) - except TemplateDoesNotExist: - # If compiling the template we found raises TemplateDoesNotExist, - # back off to returning the source and display name for the template - # we were asked to load. This allows for correct identification (later) - # of the actual template that does not exist. - self.template_cache[key] = (template, origin) - self.template_cache[key] = (template, None) - return self.template_cache[key] - - def reset(self): - """ - Empty the template cache. - """ - self.template_cache.clear() - - -class FilesystemLoader(BaseLoader): - is_usable = True - - @staticmethod - def get_template_sources(template_name, template_dirs=None): - """ - Returns the absolute paths to "template_name", when appended to each - directory in "template_dirs". Any paths that don't lie inside one of the - template dirs are excluded from the result set, for security reasons. - """ + if hasattr(connection.tenant, "pk"): + values.insert(0, force_text(connection.tenant.pk)) + + return '-'.join(values) + + +class FilesystemLoader(BaseFilesystemLoader): + """Overide django's filesystem loader.""" + + def get_template_sources(self, template_name, template_dirs=None): + """Override Django's method, replacing template dirs with setting.""" if not connection.tenant or isinstance(connection.tenant, FakeTenant): return + if not template_dirs: try: template_dirs = settings.MULTITENANT_TEMPLATE_DIRS except AttributeError: - raise ImproperlyConfigured('To use %s.%s you must define the MULTITENANT_TEMPLATE_DIRS' % - (__name__, FilesystemLoader.__name__)) + raise ImproperlyConfigured( + 'To use %s.%s you must define the MULTITENANT_TEMPLATE_DIRS' % + (__name__, FilesystemLoader.__name__) + ) + for template_dir in template_dirs: try: + name = safe_join(template_dir, template_name) if '%s' in template_dir: - yield safe_join(template_dir % connection.tenant.domain_url, template_name) + name = safe_join( + template_dir % connection.tenant.domain_url, + template_name + ) else: - yield safe_join(template_dir, connection.tenant.domain_url, template_name) - except UnicodeDecodeError: - # The template dir name was a bytestring that wasn't valid UTF-8. - raise - except ValueError: - # The joined path was located outside of this particular - # template_dir (it might be inside another one, so this isn't - # fatal). - pass - - def load_template_source(self, template_name, template_dirs=None): - tried = [] - for filepath in self.get_template_sources(template_name, template_dirs): - try: - with open(filepath, 'rb') as fp: - return fp.read().decode(settings.FILE_CHARSET), filepath - except IOError: - tried.append(filepath) - if tried: - error_msg = "Tried %s" % tried - else: - error_msg = "Your TEMPLATE_DIRS setting is empty. Change it to point to at least one template directory." - raise TemplateDoesNotExist(error_msg) - - load_template_source.is_usable = True + name = safe_join( + template_dir, + connection.tenant.domain_url, + template_name + ) + except SuspiciousFileOperation: + # The joined path was located outside of this template_dir + # (it might be inside another one, so this isn't fatal). + continue + + yield Origin( + name=name, + template_name=template_name, + loader=self, + ) diff --git a/tenant_schemas/tests/__init__.py b/tenant_schemas/tests/__init__.py index 2b6c0f2c..e69de29b 100644 --- a/tenant_schemas/tests/__init__.py +++ b/tenant_schemas/tests/__init__.py @@ -1,6 +0,0 @@ -from .template_loader import * -from .test_cache import * -from .test_log import * -from .test_routes import * -from .test_tenants import * -from .test_utils import * diff --git a/tenant_schemas/tests/multitenant/localhost/hello.html b/tenant_schemas/tests/multitenant/localhost/hello.html new file mode 100644 index 00000000..7ae79efc --- /dev/null +++ b/tenant_schemas/tests/multitenant/localhost/hello.html @@ -0,0 +1 @@ +Hello from localhost! diff --git a/tenant_schemas/tests/template_loader/__init__.py b/tenant_schemas/tests/template_loader/__init__.py deleted file mode 100644 index ac17d9d5..00000000 --- a/tenant_schemas/tests/template_loader/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .test_cached_template_loader import CachedLoaderTests diff --git a/tenant_schemas/tests/template_loader/test_cached_template_loader.py b/tenant_schemas/tests/template_loader/test_cached_template_loader.py deleted file mode 100755 index 39facfe1..00000000 --- a/tenant_schemas/tests/template_loader/test_cached_template_loader.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -from django.template.loader import get_template -from django.test import SimpleTestCase, override_settings - - -@override_settings( - TEMPLATES=[ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - os.path.join(os.path.dirname(__file__), "templates") - ], - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - ], - 'loaders': [ - ('tenant_schemas.template_loaders.CachedLoader', ( - 'tenant_schemas.template_loaders.FilesystemLoader', - 'django.template.loaders.filesystem.Loader' - )) - ] - }, - } - ] -) -class CachedLoaderTests(SimpleTestCase): - def test_get_template(self): - template = get_template("hello.html") - self.assertEqual(template.render(), "Hello! (Django templates)\n") diff --git a/tenant_schemas/tests/template_loader/templates/hello.html b/tenant_schemas/tests/templates/hello.html similarity index 100% rename from tenant_schemas/tests/template_loader/templates/hello.html rename to tenant_schemas/tests/templates/hello.html diff --git a/tenant_schemas/tests/test_template_loader.py b/tenant_schemas/tests/test_template_loader.py new file mode 100755 index 00000000..fba72541 --- /dev/null +++ b/tenant_schemas/tests/test_template_loader.py @@ -0,0 +1,61 @@ +import os + +from django.conf import settings +from django.template.loader import get_template +from django.test import SimpleTestCase, override_settings + +from tenant_schemas.utils import tenant_context + +from tenant_schemas.tests.models import Tenant +from tenant_schemas.tests.testcases import BaseTestCase + + +@override_settings( + TEMPLATES=[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.dirname(__file__), "templates") + ], + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + ], + 'loaders': [ + ('tenant_schemas.template_loaders.CachedLoader', ( + 'tenant_schemas.template_loaders.FilesystemLoader', + 'django.template.loaders.filesystem.Loader' + )) + ] + }, + } + ], + MULTITENANT_TEMPLATE_DIRS=[ + os.path.join(os.path.dirname(__file__), "multitenant") + ] +) +class LoaderTests(BaseTestCase): + """Test template loaders.""" + + @classmethod + def setUpTestData(cls): + """Create a tenant.""" + settings.SHARED_APPS = ('tenant_schemas', + 'django.contrib.contenttypes', ) + settings.TENANT_APPS = () + settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS + cls.sync_shared() + + cls.tenant = Tenant(domain_url='localhost', schema_name='public') + cls.tenant.save(verbosity=BaseTestCase.get_verbosity()) + + def test_get_template_no_tenant(self): + """Test template rendering with no tenant set in context.""" + template = get_template("hello.html") + self.assertEqual(template.render(), "Hello! (Django templates)\n") + + def test_get_template_with_tenant(self): + """Test template rendering with tenant set in context.""" + with tenant_context(self.tenant): + template = get_template("hello.html") + self.assertEqual(template.render(), "Hello from localhost!\n") diff --git a/tenant_schemas/tests/test_tenants.py b/tenant_schemas/tests/test_tenants.py index 2bf26d8a..12afdfe2 100644 --- a/tenant_schemas/tests/test_tenants.py +++ b/tenant_schemas/tests/test_tenants.py @@ -266,18 +266,29 @@ def test_command(self): settings.TENANT_APPS = () settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS self.sync_shared() - Tenant(domain_url='localhost', schema_name='public').save(verbosity=BaseTestCase.get_verbosity()) + + tenant = Tenant(domain_url='localhost', schema_name='public') + tenant.save(verbosity=BaseTestCase.get_verbosity()) out = StringIO() - call_command('tenant_command', - args=('dumpdata', 'tenant_schemas'), - natural_foreign=True, - schema_name=get_public_schema_name(), - stdout=out) - self.assertEqual( - json.loads('[{"fields": {"domain_url": "localhost", "schema_name": "public"}, ' - '"model": "tenant_schemas.tenant", "pk": 1}]'), - json.loads(out.getvalue())) + call_command( + 'tenant_command', + 'dumpdata', + 'tenant_schemas', + original_command_options={"natural_foreign": True}, + schema_name=get_public_schema_name(), + stdout=out + ) + + expected_value = [{ + 'fields': { + 'domain_url': 'localhost', + 'schema_name': 'public' + }, + 'model': 'tenant_schemas.tenant', + 'pk': tenant.pk + }] + self.assertEqual(json.loads(out.getvalue()), expected_value) class SharedAuthTest(BaseTestCase): diff --git a/tenant_schemas/urlresolvers.py b/tenant_schemas/urlresolvers.py index 0351a8a9..8a378b41 100644 --- a/tenant_schemas/urlresolvers.py +++ b/tenant_schemas/urlresolvers.py @@ -1,4 +1,4 @@ -from django.core.urlresolvers import reverse as reverse_default +from django.urls import reverse as reverse_default from django.utils.functional import lazy from tenant_schemas.utils import clean_tenant_url diff --git a/tox.ini b/tox.ini index 29ee2643..9d6cb0ea 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,14 @@ [tox] -envlist = py{27,35}-dj{18,110,111}-{standard,parallel} +envlist = + py{27,34,35,36}-dj111-{standard,parallel} + py{34,35,36}-dj20-{standard,parallel} + py{35,36}-dj21-{standard,parallel} [travis:env] DJANGO = - 1.8: dj18-{standard,parallel} - 1.10: dj110-{standard,parallel} 1.11: dj111-{standard,parallel} + 2.0: dj20-{standard,parallel} + 2.1: dj21-{standard,parallel} [testenv] usedevelop = True @@ -14,9 +17,9 @@ deps = coverage mock tblib - dj18: Django~=1.8.0 - dj110: Django~=1.10.0 dj111: Django~=1.11.0 + dj20: Django~=2.0.0 + dj21: Django>=2.1b1,<2.2 changedir = dts_test_project From 686026d046445c256edf5b5a4e10c3176ce706d8 Mon Sep 17 00:00:00 2001 From: Guillaume Andreu Sabater Date: Fri, 22 Jun 2018 02:25:05 +0200 Subject: [PATCH 2/3] updated travis conf --- .travis.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index d83cf902..4df69e32 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,16 +2,18 @@ sudo: false language: python python: - 2.7 + - 3.4 - 3.5 + - 3.6 services: - postgresql addons: postgresql: '9.4' install: pip install -q tox-travis env: - - DJANGO=1.8 - - DJANGO=1.10 - DJANGO=1.11 + - DJANGO=2.0 + - DJANGO=2.1 matrix: fast_finish: true script: tox From 88c4c64bcd29867b370311118c238fdbb2a41d89 Mon Sep 17 00:00:00 2001 From: Guillaume Andreu Sabater Date: Thu, 28 Jun 2018 18:46:53 +0200 Subject: [PATCH 3/3] updated trove classifiers --- setup.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 0176ae52..9631d7ea 100755 --- a/setup.py +++ b/setup.py @@ -30,14 +30,17 @@ description='Tenant support for Django using PostgreSQL schemas.', long_description=open('README.rst').read() if exists("README.rst") else "", classifiers=[ - 'License :: OSI Approved :: MIT License', - 'Framework :: Django', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.0', - 'Framework :: Django :: 2.1', - 'Programming Language :: Python', + "License :: OSI Approved :: MIT License", + "Framework :: Django", + "Framework :: Django :: 1.11", + "Framework :: Django :: 2.0", + "Programming Language :: Python", + "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", "Topic :: Database", "Topic :: Software Development :: Libraries", ],