diff --git a/.gitignore b/.gitignore index b5dd1eb..9b5aca5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ tags __pycache__/ *.pyc +build/ +dist/ +*.egg-info/ +.cache/ +env/ diff --git a/README.md b/README.md index b86c956..8a87528 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Detect backward incompatible migrations for your django project. Needed software (in PATH): -* `python` (only tested on python2.7 (yet)) +* `python` * `git` * UNIX command line tools like `tail` @@ -27,12 +27,6 @@ The linter will try to detect if the project is a django project and is versione ## Tests -Launch the tests with: `python -m unittest test_migration_linter` +The easiest way to run the tests is to invoke [py.test](https://docs.pytest.org/en/latest/). -More test are to be written. - -## Improvements - -* More tests (always!) -* Detect data migration (not only scheme migrations) -* Detection dependent on specified database (mysql, postgresql, ...) +More tests are always welcome. diff --git a/django_migration_linter/__init__.py b/django_migration_linter/__init__.py new file mode 100644 index 0000000..7d5a0bc --- /dev/null +++ b/django_migration_linter/__init__.py @@ -0,0 +1 @@ +from migration_linter import * # noqa diff --git a/migration_linter.py b/django_migration_linter/migration_linter.py similarity index 100% rename from migration_linter.py rename to django_migration_linter/migration_linter.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..22017db --- /dev/null +++ b/setup.py @@ -0,0 +1,65 @@ +# Copyright 2017 3YOURMIND GmbH + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from codecs import open +from os import path +from setuptools import setup, find_packages + + +PROJECT_DIR = path.abspath(path.dirname(__file__)) + +with open(path.join(PROJECT_DIR, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +install_requirements = [ +] + +test_requirements = [ + 'pytest', + 'django', + 'django-fake-database-backends' +] + +setup( + name='django-migration-linter', + version='0.0.1', + + description='Fetect backward incompatible migrations for your django project', + long_description=long_description, + url='https://github.com/3YOURMIND/django-migration-linter', + author='3YOURMIND GmbH', + license='Apache License 2.0', + + packages=find_packages(exclude=['tests/']), + install_requires=install_requirements, + extras_require={ + 'test': test_requirements + }, + + keywords='django migration lint linter database backward compatibility', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'Environment :: Web Environment', + 'Framework :: Django', + + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + # TODO verify Python 3 compatible + # 'Programming Language :: Python :: 3', + # 'Programming Language :: Python :: 3.3', + # 'Programming Language :: Python :: 3.4', + # 'Programming Language :: Python :: 3.5', + ] +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..e49a080 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,40 @@ +# Copyright 2017 3YOURMIND GmbH + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +_BASE_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'tests/') +_FIXTURES_FOLDER = os.path.join(_BASE_DIR, 'test_project_fixtures/') + +ADD_NOT_NULL_COLUMN_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_project_add_not_null_column/') +CREATE_TABLE_WITH_NOT_NULL_COLUMN_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_project_create_table_with_not_null_column/') +DROP_COLUMN_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_project_drop_column/') +RENAME_COLUMN_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_project_rename_column/') +RENAME_TABLE_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_project_rename_table/') +ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DEFAULT_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_project_add_not_null_column_followed_by_default/') +MULTI_COMMIT_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_project_multi_commit/') + +NOT_DJANGO_GIT_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_git_project/') +NOT_GIT_DJANGO_PROJECT = os.path.join( + _FIXTURES_FOLDER, 'test_django_without_git_project/') diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/functional/test_migration_linter.py b/tests/functional/test_migration_linter.py new file mode 100644 index 0000000..8943398 --- /dev/null +++ b/tests/functional/test_migration_linter.py @@ -0,0 +1,79 @@ +# Copyright 2017 3YOURMIND GmbH + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from django_migration_linter import MigrationLinter +from tests import fixtures + + +class BackwardcompatibilityDetectionTest(unittest.TestCase): + def setUp(self, *args, **kwargs): + return super( + BackwardcompatibilityDetectionTest, self).setUp(*args, **kwargs) + + def test_create_table_with_not_null_column(self): + linter = MigrationLinter( + fixtures.CREATE_TABLE_WITH_NOT_NULL_COLUMN_PROJECT) + has_errors = linter.lint_migrations() + self.assertFalse(has_errors) + + def test_detect_adding_not_null_column(self): + linter = MigrationLinter( + fixtures.ADD_NOT_NULL_COLUMN_PROJECT) + has_errors = linter.lint_migrations() + self.assertTrue(has_errors) + + def test_detect_drop_column(self): + linter = MigrationLinter( + fixtures.DROP_COLUMN_PROJECT) + has_errors = linter.lint_migrations() + self.assertTrue(has_errors) + + def test_detect_rename_column(self): + linter = MigrationLinter( + fixtures.RENAME_COLUMN_PROJECT) + has_errors = linter.lint_migrations() + self.assertTrue(has_errors) + + def test_detect_rename_table(self): + linter = MigrationLinter( + fixtures.RENAME_TABLE_PROJECT) + has_errors = linter.lint_migrations() + self.assertTrue(has_errors) + + def test_accept_not_null_column_followed_by_adding_default(self): + linter = MigrationLinter( + fixtures.ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DEFAULT_PROJECT) + has_errors = linter.lint_migrations() + self.assertFalse(has_errors) + + def test_specify_git_hash(self): + linter = MigrationLinter( + fixtures.MULTI_COMMIT_PROJECT, + commit_id=None) + has_errors = linter.lint_migrations() + self.assertTrue(has_errors) + + linter = MigrationLinter( + fixtures.MULTI_COMMIT_PROJECT, + commit_id='44330921696018f8f1480df897b38310549982cb') + has_errors = linter.lint_migrations() + self.assertFalse(has_errors) + + linter = MigrationLinter( + fixtures.MULTI_COMMIT_PROJECT, + commit_id='tag1') + has_errors = linter.lint_migrations() + self.assertFalse(has_errors) diff --git a/tests/test_project_fixtures/test_django_without_git_project/manage.py b/tests/test_project_fixtures/test_django_without_git_project/manage.py new file mode 100644 index 0000000..1462610 --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_django_without_git_project.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_app/__init__.py b/tests/test_project_fixtures/test_django_without_git_project/test_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_app/admin.py b/tests/test_project_fixtures/test_django_without_git_project/test_app/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/test_app/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_app/apps.py b/tests/test_project_fixtures/test_django_without_git_project/test_app/apps.py new file mode 100644 index 0000000..fc04070 --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/test_app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TestAppConfig(AppConfig): + name = 'test_app' diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_app/migrations/__init__.py b/tests/test_project_fixtures/test_django_without_git_project/test_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_app/models.py b/tests/test_project_fixtures/test_django_without_git_project/test_app/models.py new file mode 100644 index 0000000..137941f --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/test_app/models.py @@ -0,0 +1 @@ +from django.db import models diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_app/tests.py b/tests/test_project_fixtures/test_django_without_git_project/test_app/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/test_app/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_app/views.py b/tests/test_project_fixtures/test_django_without_git_project/test_app/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/test_app/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/__init__.py b/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/settings.py b/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/settings.py new file mode 100644 index 0000000..b91306d --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/settings.py @@ -0,0 +1,121 @@ +""" +Django settings for test_django_without_git_project project. + +Generated by 'django-admin startproject' using Django 1.11.6. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'gtb+mohs%gf0+#fr89vw7e&#edmv=q8!iv+n&j_soawk$tkj3^' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'test_app' +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'test_django_without_git_project.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'test_django_without_git_project.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/urls.py b/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/urls.py new file mode 100644 index 0000000..735de5d --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/urls.py @@ -0,0 +1,21 @@ +"""linter_test_project URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.11/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url +from django.contrib import admin + +urlpatterns = [ + url(r'^admin/', admin.site.urls), +] diff --git a/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/wsgi.py b/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/wsgi.py new file mode 100644 index 0000000..6741b31 --- /dev/null +++ b/tests/test_project_fixtures/test_django_without_git_project/test_django_without_git_project/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for linter_test_project project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project_add_not_null_column.settings") + +application = get_wsgi_application() diff --git a/tests/test_project_fixtures/test_git_project b/tests/test_project_fixtures/test_git_project new file mode 160000 index 0000000..7be2007 --- /dev/null +++ b/tests/test_project_fixtures/test_git_project @@ -0,0 +1 @@ +Subproject commit 7be2007c11ae031c7749e1fb1be9e41a8bfc1bdd diff --git a/tests/test_project_fixtures/test_project_add_not_null_column b/tests/test_project_fixtures/test_project_add_not_null_column new file mode 160000 index 0000000..13ddff9 --- /dev/null +++ b/tests/test_project_fixtures/test_project_add_not_null_column @@ -0,0 +1 @@ +Subproject commit 13ddff99f80afca39dc64eaa65928244584a88d3 diff --git a/tests/test_project_fixtures/test_project_add_not_null_column_followed_by_default b/tests/test_project_fixtures/test_project_add_not_null_column_followed_by_default new file mode 160000 index 0000000..16fd55a --- /dev/null +++ b/tests/test_project_fixtures/test_project_add_not_null_column_followed_by_default @@ -0,0 +1 @@ +Subproject commit 16fd55a08b9ad1af4282cafccf5c14adf1f2a1dc diff --git a/tests/test_project_fixtures/test_project_create_table_with_not_null_column b/tests/test_project_fixtures/test_project_create_table_with_not_null_column new file mode 160000 index 0000000..ba1eccf --- /dev/null +++ b/tests/test_project_fixtures/test_project_create_table_with_not_null_column @@ -0,0 +1 @@ +Subproject commit ba1eccf45ea15453cdb70eccb93febdfeb020b5b diff --git a/tests/test_project_fixtures/test_project_drop_column b/tests/test_project_fixtures/test_project_drop_column new file mode 160000 index 0000000..24f18b5 --- /dev/null +++ b/tests/test_project_fixtures/test_project_drop_column @@ -0,0 +1 @@ +Subproject commit 24f18b5897f6361405dd5c1c900869d693430801 diff --git a/tests/test_project_fixtures/test_project_multi_commit b/tests/test_project_fixtures/test_project_multi_commit new file mode 160000 index 0000000..dc16039 --- /dev/null +++ b/tests/test_project_fixtures/test_project_multi_commit @@ -0,0 +1 @@ +Subproject commit dc16039b7458a98bcf9be2b92389e176ae1d9461 diff --git a/tests/test_project_fixtures/test_project_rename_column b/tests/test_project_fixtures/test_project_rename_column new file mode 160000 index 0000000..0a898aa --- /dev/null +++ b/tests/test_project_fixtures/test_project_rename_column @@ -0,0 +1 @@ +Subproject commit 0a898aa4fac2fd21adeabfa9a0f89df447b0b55e diff --git a/tests/test_project_fixtures/test_project_rename_table b/tests/test_project_fixtures/test_project_rename_table new file mode 160000 index 0000000..cd1aa87 --- /dev/null +++ b/tests/test_project_fixtures/test_project_rename_table @@ -0,0 +1 @@ +Subproject commit cd1aa87abf1095d2ca6b9b64308931ab52e598c8 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_migration_linter.py b/tests/unit/test_utils.py similarity index 69% rename from test_migration_linter.py rename to tests/unit/test_utils.py index 3dfbe92..40cc1bb 100644 --- a/test_migration_linter.py +++ b/tests/unit/test_utils.py @@ -14,10 +14,11 @@ import unittest -from migration_linter import MigrationLinter +from tests import fixtures +from django_migration_linter import MigrationLinter, valid_folder -class LinterTest(unittest.TestCase): +class UtilityFunctionTest(unittest.TestCase): def test_split_migration_path(self): input_path = 'apps/the_app/migrations/0001_stuff.py' app, mig = MigrationLinter._split_migration_path(input_path) @@ -29,3 +30,12 @@ class LinterTest(unittest.TestCase): app, mig = MigrationLinter._split_migration_path(input_path) self.assertEqual(app, 'the_app') self.assertEqual(mig, '0001_stuff') + + def test_detect_valid_folder(self): + self.assertTrue(valid_folder(fixtures.ADD_NOT_NULL_COLUMN_PROJECT)) + + def test_detect_not_django_project(self): + self.assertFalse(valid_folder(fixtures.NOT_DJANGO_GIT_PROJECT)) + + def test_detect_not_git_project(self): + self.assertFalse(valid_folder(fixtures.NOT_GIT_DJANGO_PROJECT))