Files
django-migration-linter/django_migration_linter/migration_linter.py
T

349 lines
12 KiB
Python

# Copyright 2019 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 __future__ import print_function
import logging
import os
import re
from subprocess import Popen, PIPE
import sys
from .cache import Cache
from .constants import DEFAULT_CACHE_PATH, MIGRATION_FOLDER_NAME
from .migration import Migration
from .utils import is_directory, is_django_project, is_git_project, clean_bytes_to_str
from .sql_analyser import analyse_sql_statements
logger = logging.getLogger(__name__)
class MigrationLinter(object):
def __init__(self, project_path, **kwargs):
# Verify correctness
if not is_directory(project_path):
raise ValueError(
"The given path {0} does not seem to be a directory.".format(
project_path
)
)
if not is_django_project(project_path):
raise ValueError(
("The given path {0} does not " "seem to be a django project.").format(
project_path
)
)
# Store parameters and options
self.django_path = project_path
self.ignore_name_contains = kwargs.get("ignore_name_contains", None)
self.ignore_name = kwargs.get("ignore_name", None) or tuple()
self.include_apps = kwargs.get("include_apps", None)
self.exclude_apps = kwargs.get("exclude_apps", None)
self.database = kwargs.get("database", None) or "default"
self.cache_path = kwargs.get("cache_path", None) or DEFAULT_CACHE_PATH
self.no_cache = kwargs.get("no_cache", None) or False
self.python_exe = (
"{0}/bin/{1}".format(sys.prefix, "python")
if hasattr(sys, "real_prefix")
else "python"
)
# Initialise counters
self.nb_valid = 0
self.nb_ignored = 0
self.nb_erroneous = 0
self.nb_total = 0
# Initialise cache. Read from old, write to new to prune old entries.
self.old_cache = Cache(self.django_path, self.cache_path)
self.new_cache = Cache(self.django_path, self.cache_path)
if not self.no_cache:
self.old_cache.load()
def lint_migration(self, migration):
app_name = migration.app_name
migration_name = migration.name
print("({0}, {1})... ".format(app_name, migration_name), end="")
self.nb_total += 1
md5hash = self.old_cache.md5(migration.abs_path)
if md5hash in self.old_cache:
if self.old_cache[md5hash]["result"] == "IGNORE":
print("IGNORE (cached)")
self.nb_ignored += 1
elif self.old_cache[md5hash]["result"] == "OK":
print("OK (cached)")
self.nb_valid += 1
else:
print("ERR (cached)")
self.nb_erroneous += 1
if "errors" in self.old_cache[md5hash]:
self.print_errors(self.old_cache[md5hash]["errors"])
self.new_cache[md5hash] = self.old_cache[md5hash]
return
if self.should_ignore_migration(app_name, migration_name):
print("IGNORE")
self.nb_ignored += 1
return
sql_statements = self.get_sql(app_name, migration_name)
analysis_result = analyse_sql_statements(sql_statements)
errors = analysis_result["errors"]
if analysis_result["ignored"]:
print("IGNORE")
self.new_cache[md5hash] = {"result": "IGNORE"}
self.nb_ignored += 1
return
if not errors:
print("OK")
self.new_cache[md5hash] = {"result": "OK"}
self.nb_valid += 1
return
print("ERR")
self.new_cache[md5hash] = {"result": "ERR", "errors": errors}
self.nb_erroneous += 1
self.print_errors(errors)
@staticmethod
def print_errors(errors):
for err in errors:
error_str = "\t{0}".format(err["err_msg"])
if err["table"]:
error_str += " (table: {0}".format(err["table"])
if err["column"]:
error_str += ", column: {0}".format(err["column"])
error_str += ")"
print(error_str)
def lint_all_migrations(self, git_commit_id=None):
# Collect migrations
if git_commit_id:
migrations = self._gather_migrations_git(git_commit_id)
else:
migrations = self._gather_all_migrations()
# Lint those migrations
for m in migrations:
self.lint_migration(m)
if not self.no_cache:
self.new_cache.save()
def print_summary(self):
print("*** Summary:")
print(
(
"Valid migrations: {1}/{0} - "
"erroneous migrations: {2}/{0} - "
"ignored migrations: {3}/{0}"
).format(self.nb_total, self.nb_valid, self.nb_erroneous, self.nb_ignored)
)
@property
def has_errors(self):
return self.nb_erroneous > 0
def get_sql(self, app_name, migration_name):
""" It would be much faster to call the
command directly from the code using
call_command(), but requires the code
to setup django (by calling django.setup())
and set the DJANGO_SETTINGS_MODULE.
However, django is global and doesn't allow
multiple linter instances to exist at the same time.
(because they would all just lint the same django project)
Even if calling a shell is slow and ugly, for now,
it allows to seperate the instances correctly.
"""
sqlmigrate_command = (
"cd {0} && {1} manage.py sqlmigrate {2} {3} " "--database {4}"
).format(
self.django_path, self.python_exe, app_name, migration_name, self.database
)
logger.info("Executing {0}".format(sqlmigrate_command))
sqlmigrate_process = Popen(
sqlmigrate_command, shell=True, stdout=PIPE, stderr=PIPE
)
sql_statements = []
for line in map(clean_bytes_to_str, sqlmigrate_process.stdout.readlines()):
sql_statements.append(line)
sqlmigrate_process.wait()
if sqlmigrate_process.returncode != 0:
_, err = sqlmigrate_process.communicate()
raise RuntimeError(
"sqlmigrate command failed {0}".format(err.decode("utf-8"))
)
logger.info("Found {0} sql migration lines".format(len(sql_statements)))
return sql_statements
def _gather_migrations_git(self, git_commit_id):
migrations = []
# Get changes since specified commit
git_diff_command = (
"cd {0} && git diff --name-only --diff-filter=A {1}"
).format(self.django_path, git_commit_id)
logger.info("Executing {0}".format(git_diff_command))
diff_process = Popen(git_diff_command, shell=True, stdout=PIPE, stderr=PIPE)
for line in map(clean_bytes_to_str, diff_process.stdout.readlines()):
# Only gather lines that include added migrations
if (
re.search(r"\/{0}\/.*\.py".format(MIGRATION_FOLDER_NAME), line)
and "__init__" not in line
):
migrations.append(Migration(os.path.join(self.django_path, line)))
diff_process.wait()
if diff_process.returncode != 0:
output = []
for line in map(clean_bytes_to_str, diff_process.stderr.readlines()):
output.append(line)
logger.error("Error while git diff command:\n{}".format("".join(output)))
raise Exception("Error while executing git diff command")
return migrations
def _gather_all_migrations(self):
migrations = []
for root, dirs, files in os.walk(self.django_path):
for file_name in sorted(files):
if (
os.path.basename(root) == MIGRATION_FOLDER_NAME
and file_name.endswith(".py")
and file_name != "__init__.py"
):
full_migration_path = os.path.join(root, file_name)
migrations.append(Migration(full_migration_path))
return migrations
def should_ignore_migration(self, app_name, migration_name):
return (
(self.include_apps and app_name not in self.include_apps)
or (self.exclude_apps and app_name in self.exclude_apps)
or (
self.ignore_name_contains
and self.ignore_name_contains in migration_name
)
or (migration_name in self.ignore_name)
)
def _main():
import argparse
parser = argparse.ArgumentParser(
description="Detect backward incompatible django migrations."
)
parser.add_argument(
"django_folder",
metavar="DJANGO_FOLDER",
type=str,
nargs=1,
help="the path to the django project",
)
parser.add_argument(
"commit_id",
metavar="GIT_COMMIT_ID",
type=str,
nargs="?",
help=(
"if specified, only migrations since this commit "
"will be taken into account. If not specified, "
"the initial repo commit will be used"
),
)
parser.add_argument(
"--ignore-name-contains",
type=str,
nargs="?",
help="ignore migrations containing this name",
)
parser.add_argument(
"--ignore-name",
type=str,
nargs="*",
help="ignore migrations with exactly one of these names",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="print more information during execution",
)
parser.add_argument(
"--database",
type=str,
nargs="?",
help=(
"specify the database for which to generate the SQL. " "Defaults to default"
),
)
cache_group = parser.add_mutually_exclusive_group(required=False)
cache_group.add_argument(
"--cache-path",
type=str,
help="specify a directory that should be used to" "store cache-files in.",
)
cache_group.add_argument(
"--no-cache", action="store_true", help="don't use a cache"
)
incl_excl_group = parser.add_mutually_exclusive_group(required=False)
incl_excl_group.add_argument(
"--include-apps",
type=str,
nargs="*",
help="check only migrations that are in the specified django apps",
)
incl_excl_group.add_argument(
"--exclude-apps",
type=str,
nargs="*",
help="ignore migrations that are in the specified django apps",
)
args = parser.parse_args()
if args.verbose:
logging.basicConfig(format="%(message)s", level=logging.DEBUG)
else:
logging.basicConfig(format="%(message)s")
folder_name = args.django_folder[0]
# Create and use linter
linter = MigrationLinter(
folder_name,
ignore_name_contains=args.ignore_name_contains,
ignore_name=args.ignore_name,
include_apps=args.include_apps,
exclude_apps=args.exclude_apps,
database=args.database,
cache_path=args.cache_path,
no_cache=args.no_cache,
)
linter.lint_all_migrations(git_commit_id=args.commit_id)
linter.print_summary()
if linter.has_errors:
sys.exit(1)
if __name__ == "__main__":
_main()