Files
django-migration-linter/django_migration_linter/migration_linter.py
T
Dat Hoang dca50b3afb Make sure file paths from git diff are relative to django_path instead of git repos root path
Currently, the git diff command return file paths which are relative to git repos root path, which will cause invalid migration file path if git repos root path is different from django_path
2019-01-20 11:43:33 +01:00

369 lines
13 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, 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 _get_git_root(self):
git_root = self.django_path
command = "cd {0} && git rev-parse --show-toplevel".format(self.django_path)
logger.info("Executing {0}".format(command))
diff_process = Popen(command, shell=True, stdout=PIPE, stderr=PIPE)
for line in map(clean_bytes_to_str, diff_process.stdout.readlines()):
git_root = 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 rev-parse command:\n{}".format("".join(output))
)
raise Exception("Error while executing git rev-parse command")
return git_root
def _gather_migrations_git(self, git_commit_id):
migrations = []
git_root = self._get_git_root()
# Get changes since specified commit
git_diff_command = (
"cd {0} && git diff --relative --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(git_root, 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()