Migrate from setup.py and setup.cfg to pyproject.toml

This commit is contained in:
David Wobrock authored and David Wobrock committed 2023-10-29 10:56:37 +01:00
1 parent b1f1dd68bb
commit 0320cf10dd
25 files changed
+85 -85

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
from __future__ import annotations
from .migration_linter import * # noqa
from .operations import * # noqa
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import os
import pickle
class Cache(dict):
def __init__(self, django_folder: str | None, database: str, cache_path: str):
self.filename = os.path.join(
cache_path,
"{}_{}.pickle".format(str(django_folder).replace(os.sep, "_"), database),
)
if not os.path.exists(os.path.dirname(self.filename)):
os.makedirs(os.path.dirname(self.filename))
super().__init__()
def load(self) -> None:
try:
with open(self.filename, "rb") as f:
tmp_dict = pickle.load(f)
self.update(tmp_dict)
except OSError:
pass
def save(self) -> None:
with open(self.filename, "wb") as f:
pickle.dump(self, f, protocol=2)
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from appdirs import user_cache_dir
__version__ = "5.0.0"
DEFAULT_CACHE_PATH = user_cache_dir("django-migration-linter", version=__version__)
DJANGO_APPS_WITH_MIGRATIONS = ("admin", "auth", "contenttypes", "sessions")
EXPECTED_DATA_MIGRATION_ARGS = ("apps", "schema_editor")
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,234 @@
from __future__ import annotations
import configparser
import itertools
import os
import sys
from importlib import import_module
from typing import Any, Callable
import toml
from django.conf import settings
from django.core.management.base import BaseCommand, CommandParser
from ...constants import __version__
from ...migration_linter import MessageType, MigrationLinter
from ..utils import (
configure_logging,
extract_warnings_as_errors_option,
register_linting_configuration_options,
)
CONFIG_NAME = "django_migration_linter"
PYPROJECT_TOML = "pyproject.toml"
DEFAULT_CONFIG_FILES = (
f".{CONFIG_NAME}.cfg",
"setup.cfg",
"tox.ini",
)
class Command(BaseCommand):
help = "Lint your migrations"
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
"app_label",
nargs="?",
type=str,
help="App label of an application to lint migrations.",
)
parser.add_argument(
"migration_name",
nargs="?",
type=str,
help="Linting will only be done on that migration only.",
)
parser.add_argument(
"--git-commit-id",
type=str,
nargs="?",
help=(
"if specified, only migrations since this commit "
"will be taken into account"
),
)
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(
"--include-name-contains",
type=str,
nargs="?",
help="only consider migrations containing this name",
)
parser.add_argument(
"--include-name",
type=str,
nargs="*",
help="only consider migrations with exactly one of these names",
)
parser.add_argument(
"--project-root-path", type=str, nargs="?", help="django project root path"
)
parser.add_argument(
"--include-migrations-from",
metavar="FILE_PATH",
type=str,
nargs="?",
help="if specified, only migrations listed in the file will be considered",
)
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",
)
applied_unapplied_migrations_group = parser.add_mutually_exclusive_group(
required=False
)
applied_unapplied_migrations_group.add_argument(
"--unapplied-migrations",
action="store_true",
help="check only migrations have not been applied to the database yet",
)
applied_unapplied_migrations_group.add_argument(
"--applied-migrations",
action="store_true",
help="check only migrations that have already been applied to the database",
)
parser.add_argument(
"-q",
"--quiet",
nargs="+",
choices=MessageType.values(),
help="don't print linting messages to stdout",
)
register_linting_configuration_options(parser)
def handle(self, *args, **options):
django_settings_options = self.read_django_settings(options)
config_options = self.read_config_file(options)
toml_options = self.read_toml_file(options)
for k, v in itertools.chain(
django_settings_options.items(),
config_options.items(),
toml_options.items(),
):
if not options[k]:
options[k] = v
(
warnings_as_errors_tests,
all_warnings_as_errors,
) = extract_warnings_as_errors_option(options["warnings_as_errors"])
configure_logging(options["verbosity"])
root_path = options["project_root_path"] or os.path.dirname(
import_module(os.getenv("DJANGO_SETTINGS_MODULE")).__file__
)
linter = MigrationLinter(
root_path,
ignore_name_contains=options["ignore_name_contains"],
ignore_name=options["ignore_name"],
include_name_contains=options["include_name_contains"],
include_name=options["include_name"],
include_apps=options["include_apps"],
exclude_apps=options["exclude_apps"],
database=options["database"],
cache_path=options["cache_path"],
no_cache=options["no_cache"],
only_applied_migrations=options["applied_migrations"],
only_unapplied_migrations=options["unapplied_migrations"],
exclude_migration_tests=options["exclude_migration_tests"],
quiet=options["quiet"],
warnings_as_errors_tests=warnings_as_errors_tests,
all_warnings_as_errors=all_warnings_as_errors,
no_output=options["verbosity"] == 0,
analyser_string=options["sql_analyser"],
)
linter.lint_all_migrations(
app_label=options["app_label"],
migration_name=options["migration_name"],
git_commit_id=options["git_commit_id"],
migrations_file_path=options["include_migrations_from"],
)
linter.print_summary()
if linter.has_errors:
sys.exit(1)
@staticmethod
def read_django_settings(options: dict[str, Any]) -> dict[str, Any]:
django_settings_options = dict()
django_migration_linter_settings = getattr(
settings, "MIGRATION_LINTER_OPTIONS", dict()
)
for key in options:
if key in django_migration_linter_settings:
django_settings_options[key] = django_migration_linter_settings[key]
return django_settings_options
@staticmethod
def read_config_file(options: dict[str, Any]) -> dict[str, Any]:
config_options = dict()
config_parser = configparser.ConfigParser()
config_parser.read(DEFAULT_CONFIG_FILES, encoding="utf-8")
for key, value in options.items():
config_get_fn: Callable
if isinstance(value, bool):
config_get_fn = config_parser.getboolean
else:
config_get_fn = config_parser.get
config_value = config_get_fn(CONFIG_NAME, key, fallback=None)
if config_value is not None:
config_options[key] = config_value
return config_options
@staticmethod
def read_toml_file(options: dict[str, Any]) -> dict[str, Any]:
toml_options = dict()
if os.path.exists(PYPROJECT_TOML):
pyproject_toml = toml.load(PYPROJECT_TOML)
section = pyproject_toml.get("tool", {}).get(CONFIG_NAME, {})
for key in options:
if key in section:
toml_options[key] = section[key]
return toml_options
def get_version(self) -> str:
return __version__
@@ -0,0 +1,123 @@
from __future__ import annotations
import os
from django.conf import settings
from django.core.management import CommandParser
from django.core.management.commands.makemigrations import (
Command as MakeMigrationsCommand,
)
from django.db.migrations import Migration
from django.db.migrations.questioner import InteractiveMigrationQuestioner
from django.db.migrations.writer import MigrationWriter
from django_migration_linter import MigrationLinter
from ..utils import (
configure_logging,
extract_warnings_as_errors_option,
register_linting_configuration_options,
)
def ask_should_keep_migration() -> bool:
questioner = InteractiveMigrationQuestioner()
msg = """
The migration linter detected that this migration is not backward compatible.
- If you keep the migration, you will want to fix the issue or ignore the migration.
- By default, the newly created migration file will be deleted.
Do you want to keep the migration? [y/N]"""
return questioner._boolean_input(msg, False)
def default_should_keep_migration():
return False
class Command(MakeMigrationsCommand):
help = "Creates new migration(s) for apps and lints them."
def add_arguments(self, parser: CommandParser) -> None:
super().add_arguments(parser)
parser.add_argument(
"--lint",
action="store_true",
help="Lint newly generated migrations.",
)
register_linting_configuration_options(parser)
def handle(self, *app_labels, **options):
self.lint = options["lint"]
self.database = options["database"]
self.exclude_migrations_tests = options["exclude_migration_tests"]
self.warnings_as_errors = options["warnings_as_errors"]
self.sql_analyser = options["sql_analyser"]
configure_logging(options["verbosity"])
return super().handle(*app_labels, **options)
def write_migration_files(self, changes: dict[str, list[Migration]]) -> None:
super().write_migration_files(changes)
if (
not getattr(settings, "MIGRATION_LINTER_OVERRIDE_MAKEMIGRATIONS", False)
and not self.lint
):
return
if self.dry_run:
"""
Since we rely on the 'sqlmigrate' to lint the migrations, we can only
lint if the migration files have been generated. Since the 'dry-run'
option won't generate the files, we cannot lint migrations.
"""
return
should_keep_migration = (
ask_should_keep_migration
if self.interactive
else default_should_keep_migration
)
(
warnings_as_errors_tests,
all_warnings_as_errors,
) = extract_warnings_as_errors_option(self.warnings_as_errors)
# Lint migrations.
linter = MigrationLinter(
path=os.environ["DJANGO_SETTINGS_MODULE"],
database=self.database,
no_cache=True,
exclude_migration_tests=self.exclude_migrations_tests,
warnings_as_errors_tests=warnings_as_errors_tests,
all_warnings_as_errors=all_warnings_as_errors,
analyser_string=self.sql_analyser,
)
for app_label, app_migrations in changes.items():
if self.verbosity >= 1:
self.stdout.write(
self.style.MIGRATE_HEADING("Linting for '%s':" % app_label) + "\n"
)
for migration in app_migrations:
linter.lint_migration(migration)
if linter.has_errors:
if not should_keep_migration():
self.delete_migration(migration)
linter.reset_counters()
def delete_migration(self, migration: Migration) -> None:
writer = MigrationWriter(migration)
os.remove(writer.path)
if self.verbosity >= 1:
try:
migration_string = os.path.relpath(writer.path)
except ValueError:
migration_string = writer.path
if migration_string.startswith(".."):
migration_string = writer.path
self.stdout.write(
"Deleted %s\n" % (self.style.MIGRATE_LABEL(migration_string))
)
@@ -0,0 +1,67 @@
from __future__ import annotations
import logging
from django.core.management import CommandParser
from ..sql_analyser.analyser import ANALYSER_STRING_MAPPING
def register_linting_configuration_options(parser: CommandParser) -> None:
parser.add_argument(
"--database",
type=str,
nargs="?",
help=(
"specify the database for which to generate the SQL. Defaults to default"
),
)
parser.add_argument(
"--exclude-migration-tests",
type=str,
nargs="*",
help="Specify backward incompatible migration tests "
"to be ignored (e.g. ALTER_COLUMN)",
)
parser.add_argument(
"--warnings-as-errors",
type=str,
nargs="*",
help="handle warnings as errors. Optionally specify the tests to handle as "
"errors (e.g. RUNPYTHON_REVERSIBLE)",
)
parser.add_argument(
"--sql-analyser",
nargs="?",
choices=list(ANALYSER_STRING_MAPPING.keys()),
help="select the SQL analyser",
)
def configure_logging(verbosity: int) -> None:
logger = logging.getLogger("django_migration_linter")
if verbosity > 1:
logging.basicConfig(format="%(message)s", level=logging.DEBUG)
elif verbosity == 0:
logger.disabled = True
else:
logging.basicConfig(format="%(message)s")
def extract_warnings_as_errors_option(
warnings_as_errors: list[str] | None,
) -> tuple[list[str] | None, bool]:
if isinstance(warnings_as_errors, list):
warnings_as_errors_tests = warnings_as_errors
# If the option is specified but without any test codes,
# all warnings become errors
all_warnings_as_errors = len(warnings_as_errors) == 0
else:
warnings_as_errors_tests = None
all_warnings_as_errors = False
return warnings_as_errors_tests, all_warnings_as_errors
@@ -0,0 +1,709 @@
from __future__ import annotations
import functools
import hashlib
import inspect
import logging
import os
import re
from enum import Enum, unique
from subprocess import PIPE, Popen
from typing import Callable, Iterable
from django.conf import settings
from django.core.management import call_command
from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connections
from django.db.migrations import Migration, RunPython, RunSQL
from django.db.migrations.operations.base import Operation
from .cache import Cache
from .constants import (
DEFAULT_CACHE_PATH,
DJANGO_APPS_WITH_MIGRATIONS,
EXPECTED_DATA_MIGRATION_ARGS,
)
from .operations import IgnoreMigration
from .sql_analyser import analyse_sql_statements, get_sql_analyser_class
from .sql_analyser.base import Issue
from .utils import clean_bytes_to_str, get_migration_abspath, split_migration_path
logger = logging.getLogger("django_migration_linter")
@unique
class MessageType(Enum):
OK = "ok"
IGNORE = "ignore"
WARNING = "warning"
ERROR = "error"
@staticmethod
def values() -> list[str]:
return list(map(lambda c: c.value, MessageType))
class MigrationLinter:
def __init__(
self,
path: str | None = None,
ignore_name_contains: str | None = None,
ignore_name: Iterable[str] | None = None,
include_name_contains: str | None = None,
include_name: Iterable[str] | None = None,
include_apps: Iterable[str] | None = None,
exclude_apps: Iterable[str] | None = None,
database: str = DEFAULT_DB_ALIAS,
cache_path: str = DEFAULT_CACHE_PATH,
no_cache: bool = False,
only_applied_migrations: bool = False,
only_unapplied_migrations: bool = False,
exclude_migration_tests: Iterable[str] | None = None,
quiet: Iterable[str] | None = None,
warnings_as_errors_tests: Iterable[str] | None = None,
all_warnings_as_errors: bool = False,
no_output: bool = False,
analyser_string: str | None = None,
):
# Store parameters and options
self.django_path = path
self.ignore_name_contains = ignore_name_contains
self.ignore_name = ignore_name or []
self.include_name_contains = include_name_contains
self.include_name = include_name or []
self.include_apps = include_apps
self.exclude_apps = exclude_apps
self.exclude_migration_tests = exclude_migration_tests or []
self.database = database or DEFAULT_DB_ALIAS
self.cache_path = cache_path or DEFAULT_CACHE_PATH
self.no_cache = no_cache
self.only_applied_migrations = only_applied_migrations
self.only_unapplied_migrations = only_unapplied_migrations
self.quiet = quiet or []
self.warnings_as_errors_tests = warnings_as_errors_tests
self.all_warnings_as_errors = all_warnings_as_errors
self.no_output = no_output
self.sql_analyser_class = get_sql_analyser_class(
settings.DATABASES[self.database]["ENGINE"],
analyser_string=analyser_string,
)
# Initialise counters
self.reset_counters()
# Initialise cache. Read from old, write to new, in order to prune old entries.
if self.should_use_cache():
self.old_cache = Cache(self.django_path, self.database, self.cache_path)
self.new_cache = Cache(self.django_path, self.database, self.cache_path)
self.old_cache.load()
# Initialise migrations
from django.db.migrations.loader import MigrationLoader
self.migration_loader = MigrationLoader(
connection=connections[self.database], load=True
)
def reset_counters(self) -> None:
self.nb_valid = 0
self.nb_ignored = 0
self.nb_warnings = 0
self.nb_erroneous = 0
self.nb_total = 0
def should_use_cache(self) -> bool:
return bool(self.django_path and not self.no_cache)
def lint_all_migrations(
self,
app_label: str | None = None,
migration_name: str | None = None,
git_commit_id: str | None = None,
migrations_file_path: str | None = None,
) -> None:
# Collect migrations.
migrations_list = self.read_migrations_list(migrations_file_path)
if git_commit_id:
migrations = self._gather_migrations_git(git_commit_id, migrations_list)
else:
migrations = self._gather_all_migrations(migrations_list)
# Lint those migrations.
sorted_migrations = sorted(
migrations, key=lambda migration: (migration.app_label, migration.name)
)
specific_target_migration = (
self.migration_loader.get_migration_by_prefix(app_label, migration_name)
if app_label and migration_name
else None
)
for m in sorted_migrations:
if app_label and migration_name:
if m == specific_target_migration:
self.lint_migration(m)
elif app_label:
if m.app_label == app_label:
self.lint_migration(m)
else:
self.lint_migration(m)
if self.should_use_cache():
self.new_cache.save()
def lint_migration(self, migration: Migration) -> None:
app_label = migration.app_label
migration_name = migration.name
operations = migration.operations
self.nb_total += 1
md5hash = self.get_migration_hash(app_label, migration_name)
if self.should_ignore_migration(app_label, migration_name, operations):
self.print_linting_msg(
app_label, migration_name, "IGNORE", MessageType.IGNORE
)
self.nb_ignored += 1
return
if self.should_use_cache() and md5hash in self.old_cache:
self.lint_cached_migration(app_label, migration_name, md5hash)
return
sql_statements = self.get_sql(app_label, migration_name)
errors, ignored, warnings = analyse_sql_statements(
self.sql_analyser_class,
sql_statements,
self.exclude_migration_tests,
)
err, ignored_data, warnings_data = self.analyse_data_migration(migration)
if err:
errors += err
if ignored_data:
ignored += ignored_data
if warnings_data:
warnings += warnings_data
if self.all_warnings_as_errors:
errors += warnings
warnings = []
elif self.warnings_as_errors_tests:
new_warnings = []
for w in warnings:
if w.code in self.warnings_as_errors_tests:
errors.append(w)
else:
new_warnings.append(w)
warnings = new_warnings
# Fixme: have a more generic approach to handling errors/warnings/ignored/ok?
if errors:
self.print_linting_msg(app_label, migration_name, "ERR", MessageType.ERROR)
self.nb_erroneous += 1
self.print_errors(errors)
if warnings:
self.print_warnings(warnings)
value_to_cache = {"result": "ERR", "errors": errors, "warnings": warnings}
elif warnings:
self.print_linting_msg(
app_label, migration_name, "WARNING", MessageType.WARNING
)
self.nb_warnings += 1
self.print_warnings(warnings)
value_to_cache = {"result": "WARNING", "warnings": warnings}
# Fixme: not displaying ignored errors, when
else:
if ignored:
self.print_linting_msg(
app_label, migration_name, "OK (ignored)", MessageType.IGNORE
)
self.print_errors(ignored)
else:
self.print_linting_msg(app_label, migration_name, "OK", MessageType.OK)
self.nb_valid += 1
value_to_cache = {"result": "OK"}
if self.should_use_cache():
self.new_cache[md5hash] = value_to_cache
@staticmethod
def get_migration_hash(app_label: str, migration_name: str) -> str:
hash_md5 = hashlib.md5()
with open(get_migration_abspath(app_label, migration_name), "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def lint_cached_migration(
self, app_label: str, migration_name: str, md5hash: str
) -> None:
cached_value = self.old_cache[md5hash]
if cached_value["result"] == "IGNORE":
self.print_linting_msg(
app_label, migration_name, "IGNORE (cached)", MessageType.IGNORE
)
self.nb_ignored += 1
elif cached_value["result"] == "OK":
self.print_linting_msg(
app_label, migration_name, "OK (cached)", MessageType.OK
)
self.nb_valid += 1
elif cached_value["result"] == "WARNING":
self.print_linting_msg(
app_label, migration_name, "WARNING (cached)", MessageType.WARNING
)
self.nb_warnings += 1
self.print_warnings(cached_value["warnings"])
else:
self.print_linting_msg(
app_label, migration_name, "ERR (cached)", MessageType.ERROR
)
self.nb_erroneous += 1
if "errors" in cached_value:
self.print_errors(cached_value["errors"])
if "warnings" in cached_value and cached_value["warnings"]:
self.print_warnings(cached_value["warnings"])
self.new_cache[md5hash] = cached_value
def print_linting_msg(
self, app_label: str, migration_name: str, msg: str, lint_result: MessageType
) -> None:
if lint_result.value in self.quiet:
return
if not self.no_output:
print(f"({app_label}, {migration_name})... {msg}")
def print_errors(self, errors: list[Issue]) -> None:
if MessageType.ERROR.value in self.quiet:
return
for err in errors:
error_str = "\t{}".format(err.message)
if err.table:
error_str += " (table: {}".format(err.table)
if err.column:
error_str += ", column: {}".format(err.column)
error_str += ")"
if not self.no_output:
print(error_str)
def print_warnings(self, warnings: list[Issue]) -> None:
if MessageType.WARNING.value in self.quiet:
return
for warning_details in warnings:
warn_str = "\t{}".format(warning_details.message)
if not self.no_output:
print(warn_str)
def print_summary(self) -> None:
if self.no_output:
return
print("*** Summary ***")
print(f"Valid migrations: {self.nb_valid}/{self.nb_total}")
print(f"Erroneous migrations: {self.nb_erroneous}/{self.nb_total}")
print(f"Migrations with warnings: {self.nb_warnings}/{self.nb_total}")
print(f"Ignored migrations: {self.nb_ignored}/{self.nb_total}")
@property
def has_errors(self) -> bool:
return self.nb_erroneous > 0
def get_sql(self, app_label: str, migration_name: str) -> list[str]:
logger.info(f"Calling sqlmigrate command {app_label} {migration_name}")
try:
with open(os.devnull, "w") as dev_null:
sql_statement = call_command(
"sqlmigrate",
app_label,
migration_name,
database=self.database,
stdout=dev_null,
)
except (ValueError, ProgrammingError):
logger.warning(
"Error while executing sqlmigrate on (%s, %s).",
app_label,
migration_name,
)
raise
return sql_statement.splitlines()
@staticmethod
def is_migration_file(filename: str) -> bool:
from django.db.migrations.loader import MIGRATIONS_MODULE_NAME
return bool(
re.search(rf"/{MIGRATIONS_MODULE_NAME}/.*\.py", filename)
and "__init__" not in filename
)
@classmethod
def read_migrations_list(
cls, migrations_file_path: str | None
) -> list[tuple[str, str]] | None:
"""
Returning an empty list is different from returning None here.
None: no file was specified and we should consider all migrations
Empty list: no migration found in the file and we should consider no migration
"""
if not migrations_file_path:
return None
migrations = []
try:
with open(migrations_file_path) as file:
for line in file:
if cls.is_migration_file(line):
app_label, name = split_migration_path(line)
migrations.append((app_label, name))
except OSError:
logger.exception("Migrations list path not found %s", migrations_file_path)
raise Exception("Error while reading migrations list file")
if not migrations:
logger.info(
"No valid migration paths found in the migrations file %s",
migrations_file_path,
)
return migrations
def _gather_migrations_git(
self, git_commit_id: str, migrations_list: list[tuple[str, str]] | None = None
) -> Iterable[Migration]:
migrations = []
# Get changes since specified commit
git_diff_command = (
"cd {} && git diff --relative --name-only --diff-filter=AR {}"
).format(self.django_path, git_commit_id)
logger.info(f"Executing {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() # type: ignore
):
# Only gather lines that include added migrations
if self.is_migration_file(line):
app_label, name = split_migration_path(line)
if migrations_list is None or (app_label, name) in migrations_list:
if (app_label, name) in self.migration_loader.disk_migrations:
migration = self.migration_loader.disk_migrations[
app_label, name
]
migrations.append(migration)
else:
logger.info(
"Found migration file (%s, %s) "
"that is not present in loaded migration.",
app_label,
name,
)
diff_process.wait()
if diff_process.returncode != 0:
output = []
for line in map(
clean_bytes_to_str, diff_process.stderr.readlines() # type: ignore
):
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_list: list[tuple[str, str]] | None = None
) -> Iterable[Migration]:
for (
(app_label, name),
migration,
) in self.migration_loader.disk_migrations.items():
if app_label not in DJANGO_APPS_WITH_MIGRATIONS: # Prune Django apps
if migrations_list is None or (app_label, name) in migrations_list:
yield migration
def should_ignore_migration(
self, app_label: str, migration_name: str, operations: Iterable[Operation] = ()
) -> bool:
return (
(self.include_apps and app_label not in self.include_apps)
or (self.exclude_apps and app_label in self.exclude_apps)
or any(isinstance(o, IgnoreMigration) for o in operations)
or (
self.ignore_name_contains
and self.ignore_name_contains in migration_name
)
or (
self.include_name_contains
and self.include_name_contains not in migration_name
)
or (migration_name in self.ignore_name)
or (self.include_name and migration_name not in self.include_name)
or (
self.only_applied_migrations
and (app_label, migration_name)
not in self.migration_loader.applied_migrations
)
or (
self.only_unapplied_migrations
and (app_label, migration_name)
in self.migration_loader.applied_migrations
)
)
def analyse_data_migration(
self, migration: Migration
) -> tuple[list[Issue], list[Issue], list[Issue]]:
errors = []
ignored = []
warnings = []
for operation in migration.operations:
if isinstance(operation, RunPython):
op_errors, op_ignored, op_warnings = self.lint_runpython(operation)
elif isinstance(operation, RunSQL):
op_errors, op_ignored, op_warnings = self.lint_runsql(operation)
else:
op_errors, op_ignored, op_warnings = [], [], []
if op_errors:
errors += op_errors
if op_ignored:
ignored += op_ignored
if op_warnings:
warnings += op_warnings
return errors, ignored, warnings
@staticmethod
def discover_function(function):
if isinstance(function, functools.partial):
return function.func
return function
def lint_runpython(
self, runpython: RunPython
) -> tuple[list[Issue], list[Issue], list[Issue]]:
function_name = self.discover_function(runpython.code).__name__
error = []
ignored = []
warning = []
# Detect warning on missing reverse operation
if not runpython.reversible:
issue = Issue(
code="RUNPYTHON_REVERSIBLE",
message="'{}': RunPython data migration is not reversible".format(
function_name
),
)
if issue.code in self.exclude_migration_tests:
ignored.append(issue)
else:
warning.append(issue)
# Detect warning for argument naming convention
args_spec = inspect.getfullargspec(runpython.code)
if tuple(args_spec.args) != EXPECTED_DATA_MIGRATION_ARGS:
issue = Issue(
code="RUNPYTHON_ARGS_NAMING_CONVENTION",
message=(
"'{}': By convention, "
"RunPython names the two arguments: apps, schema_editor"
).format(function_name),
)
if issue.code in self.exclude_migration_tests:
ignored.append(issue)
else:
warning.append(issue)
# Detect wrong model imports
# Forward
issues = self.get_runpython_model_import_issues(runpython.code)
for issue in issues:
if issue.code in self.exclude_migration_tests:
ignored.append(issue)
else:
error.append(issue)
# Backward
if runpython.reversible:
issues = self.get_runpython_model_import_issues(runpython.reverse_code)
for issue in issues:
if issue and issue.code in self.exclude_migration_tests:
ignored.append(issue)
else:
error.append(issue)
# Detect warning if model variable name is not the same as model class
issues = self.get_runpython_model_variable_naming_issues(runpython.code)
for issue in issues:
if issue and issue.code in self.exclude_migration_tests:
ignored.append(issue)
else:
warning.append(issue)
if runpython.reversible:
issues = self.get_runpython_model_variable_naming_issues(
runpython.reverse_code
)
for issue in issues:
if issue and issue.code in self.exclude_migration_tests:
ignored.append(issue)
else:
warning.append(issue)
return error, ignored, warning
@staticmethod
def get_runpython_model_import_issues(code: Callable) -> list[Issue]:
model_object_regex = re.compile(r"[^a-zA-Z0-9._]?([a-zA-Z0-9._]+?)\.objects")
function = MigrationLinter.discover_function(code)
function_name = function.__name__
source_code = inspect.getsource(function)
called_models = model_object_regex.findall(source_code)
issues = []
for model in called_models:
model = model.split(".", 1)[0]
has_get_model_call = (
re.search(
rf"{model}.*= +\w+\.get_model\(",
source_code,
)
is not None
)
if not has_get_model_call:
issues.append(
Issue(
code="RUNPYTHON_MODEL_IMPORT",
message=(
"'{}': Could not find an 'apps.get_model(\"...\", \"{}\")' "
"call. Importing the model directly is incorrect for "
"data migrations."
).format(function_name, model),
)
)
return issues
@staticmethod
def get_runpython_model_variable_naming_issues(code: Callable) -> list[Issue]:
model_object_regex = re.compile(r"[^a-zA-Z]?([a-zA-Z0-9]+?)\.objects")
function = MigrationLinter.discover_function(code)
function_name = function.__name__
source_code = inspect.getsource(function)
called_models = model_object_regex.findall(source_code)
issues = []
for model in called_models:
has_same_model_name = (
re.search(
r"{model}.*= +\w+?\.get_model\([^)]+?\.{model}.*?\)".format(
model=model
),
source_code,
re.MULTILINE | re.DOTALL,
)
is not None
or re.search(
r"{model}.*= +\w+?\.get_model\([^)]+?,[^)]*?{model}.*?\)".format(
model=model
),
source_code,
re.MULTILINE | re.DOTALL,
)
is not None
)
if not has_same_model_name:
issues.append(
Issue(
code="RUNPYTHON_MODEL_VARIABLE_NAME",
message=(
"'{}': Model variable name {} is different from the "
"model class name that was found in the "
"apps.get_model(...) call."
).format(function_name, model),
)
)
return issues
def lint_runsql(
self, runsql: RunSQL
) -> tuple[list[Issue], list[Issue], list[Issue]]:
error = []
ignored = []
warning = []
# Detect warning on missing reverse operation
if not runsql.reversible:
issue = Issue(
code="RUNSQL_REVERSIBLE",
message="RunSQL data migration is not reversible",
)
if issue.code in self.exclude_migration_tests:
ignored.append(issue)
else:
warning.append(issue)
# Put the SQL in our SQL analyser
if runsql.sql != RunSQL.noop:
sql_statements = []
if isinstance(runsql.sql, (list, tuple)):
for sql in runsql.sql:
params = None
if isinstance(sql, (list, tuple)):
elements = len(sql)
if elements == 2:
sql, params = sql
else:
raise ValueError("Expected a 2-tuple but got %d" % elements)
sql_statements.append(sql % params)
else:
sql_statements.append(sql)
else:
sql_statements.append(runsql.sql)
sql_errors, sql_ignored, sql_warnings = analyse_sql_statements(
self.sql_analyser_class,
sql_statements,
self.exclude_migration_tests,
)
if sql_errors:
error += sql_errors
if sql_ignored:
ignored += sql_ignored
if sql_warnings:
warning += sql_warnings
# And analysse the reverse SQL
if runsql.reversible and runsql.reverse_sql != RunSQL.noop:
sql_statements = []
if isinstance(runsql.reverse_sql, (list, tuple)):
for sql in runsql.reverse_sql:
params = None
if isinstance(sql, (list, tuple)):
elements = len(sql)
if elements == 2:
sql, params = sql
else:
raise ValueError("Expected a 2-tuple but got %d" % elements)
sql_statements.append(sql % params)
else:
sql_statements.append(sql)
else:
sql_statements.append(runsql.reverse_sql)
sql_errors, sql_ignored, sql_warnings = analyse_sql_statements(
self.sql_analyser_class,
sql_statements,
self.exclude_migration_tests,
)
if sql_errors:
error += sql_errors
if sql_ignored:
ignored += sql_ignored
if sql_warnings:
warning += sql_warnings
return error, ignored, warning
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from django.db.migrations.operations.base import Operation
class IgnoreMigration(Operation):
"""
No-op migration operation that will enable the Django Migration Linter
to detect if the entire migration should be ignored (through code).
"""
reversible = True
reduces_to_sql = False
elidable = True
def state_forwards(self, app_label, state):
pass
def database_forwards(self, app_label, schema_editor, from_state, to_state):
pass
def database_backwards(self, app_label, schema_editor, from_state, to_state):
pass
def describe(self):
return "The Django migration linter will ignore this migration"
@@ -0,0 +1,8 @@
from __future__ import annotations
from .base import BaseAnalyser # noqa
from .mysql import MySqlAnalyser # noqa
from .postgresql import PostgresqlAnalyser # noqa
from .sqlite import SqliteAnalyser # noqa
from .analyser import analyse_sql_statements, get_sql_analyser_class # noqa isort:skip
@@ -0,0 +1,70 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Iterable, Type
if TYPE_CHECKING:
from sql_analyser.base import Issue
from django_migration_linter.sql_analyser import (
BaseAnalyser,
MySqlAnalyser,
PostgresqlAnalyser,
SqliteAnalyser,
)
logger = logging.getLogger("django_migration_linter")
ANALYSER_STRING_MAPPING: dict[str, Type[BaseAnalyser]] = {
"sqlite": SqliteAnalyser,
"mysql": MySqlAnalyser,
"postgresql": PostgresqlAnalyser,
}
def get_sql_analyser_class(
database_vendor: str, analyser_string: str | None = None
) -> Type[BaseAnalyser]:
if analyser_string:
return get_sql_analyser_from_string(analyser_string)
return get_sql_analyser_class_from_db_vendor(database_vendor)
def get_sql_analyser_from_string(analyser_string: str) -> Type[BaseAnalyser]:
if analyser_string not in ANALYSER_STRING_MAPPING:
raise ValueError(
"Unknown SQL analyser '{}'. Known values: '{}'".format(
analyser_string,
"','".join(ANALYSER_STRING_MAPPING.keys()),
)
)
return ANALYSER_STRING_MAPPING[analyser_string]
def get_sql_analyser_class_from_db_vendor(database_vendor: str) -> Type[BaseAnalyser]:
sql_analyser_class: Type[BaseAnalyser]
if "mysql" in database_vendor:
sql_analyser_class = MySqlAnalyser
elif "postgre" in database_vendor:
sql_analyser_class = PostgresqlAnalyser
elif "sqlite" in database_vendor:
sql_analyser_class = SqliteAnalyser
else:
raise ValueError(
"Unsupported database vendor '{}'. Try specifying an SQL analyser.".format(
database_vendor
)
)
logger.debug("Chosen SQL analyser class: %s", sql_analyser_class)
return sql_analyser_class
def analyse_sql_statements(
sql_analyser_class: Type[BaseAnalyser],
sql_statements: list[str],
exclude_migration_tests: Iterable[str] | None = None,
) -> tuple[list[Issue], list[Issue], list[Issue]]:
sql_analyser = sql_analyser_class(exclude_migration_tests)
sql_analyser.analyse(sql_statements)
return sql_analyser.errors, sql_analyser.ignored, sql_analyser.warnings
@@ -0,0 +1,232 @@
from __future__ import annotations
import logging
import re
from copy import deepcopy
from dataclasses import dataclass
from enum import Enum
from typing import Callable, Iterable
logger = logging.getLogger("django_migration_linter")
def find_check_from_code(checks: list[Check], code: str) -> Check | None:
return next((c for c in checks if c.code == code), None)
def update_migration_checks(
base_checks: list[Check], specific_checks: list[Check]
) -> list[Check]:
new_checks = deepcopy(base_checks)
for override_check in specific_checks:
migration_check = find_check_from_code(new_checks, override_check.code)
if migration_check is None or not override_check.code:
migration_check = deepcopy(override_check)
new_checks.append(migration_check)
for field in override_check.__dataclass_fields__:
setattr(migration_check, field, getattr(override_check, field))
return new_checks
def has_not_null_column(sql_statements: list[str], **kwargs) -> bool:
# TODO: improve to detect that the same column is concerned
ends_with_default = False
for sql in sql_statements:
if "SET DEFAULT" in sql:
ends_with_default = True
elif "DROP DEFAULT" in sql:
ends_with_default = False
return (
any(
re.search("(?<!DROP )NOT NULL", sql)
and not (sql.startswith("CREATE TABLE") or sql.startswith("CREATE INDEX"))
for sql in sql_statements
)
and ends_with_default is False
)
def has_add_unique(sql_statements: list[str], **kwargs) -> bool:
regex_result = None
for sql in sql_statements:
regex_result = re.search(
"ALTER TABLE (.*) ADD CONSTRAINT .* UNIQUE", sql
) or re.search('CREATE UNIQUE INDEX .* ON (".*?")', sql)
if regex_result:
break
if not regex_result:
return False
concerned_table = regex_result.group(1)
table_is_added_in_transaction = any(
sql.startswith(f"CREATE TABLE {concerned_table}") for sql in sql_statements
)
return not table_is_added_in_transaction
class CheckMode(Enum):
"""
Defines whether the Check.fn gets a str or a list[str] as first parameter.
"""
ONE_LINER = 1
TRANSACTION = 2
class CheckType(Enum):
ERROR = 1
WARNING = 2
@dataclass
class Check:
"""
Represents a check that will be done against SQL statement(s).
"""
code: str
fn: Callable
message: str
mode: CheckMode
type: CheckType
@dataclass
class Issue:
code: str
message: str
table: str | None = None
column: str | None = None
class BaseAnalyser:
base_migration_checks: list[Check] = [
Check(
code="RENAME_TABLE",
fn=lambda sql, **kw: re.search("RENAME TABLE", sql)
or re.search("ALTER TABLE .* RENAME TO", sql),
message="RENAMING tables",
mode=CheckMode.ONE_LINER,
type=CheckType.ERROR,
),
Check(
code="NOT_NULL",
fn=has_not_null_column,
message="NOT NULL constraint on columns",
mode=CheckMode.TRANSACTION,
type=CheckType.ERROR,
),
Check(
code="DROP_COLUMN",
fn=lambda sql, **kw: re.search("DROP COLUMN", sql),
message="DROPPING columns",
mode=CheckMode.ONE_LINER,
type=CheckType.ERROR,
),
Check(
code="DROP_TABLE",
fn=lambda sql, **kw: sql.startswith("DROP TABLE"),
message="DROPPING table",
mode=CheckMode.ONE_LINER,
type=CheckType.ERROR,
),
Check(
code="RENAME_COLUMN",
fn=lambda sql, **kw: re.search("ALTER TABLE .* CHANGE", sql)
or re.search("ALTER TABLE .* RENAME COLUMN", sql),
message="RENAMING columns",
mode=CheckMode.ONE_LINER,
type=CheckType.ERROR,
),
Check(
code="ALTER_COLUMN",
fn=lambda sql, **kw: re.search("ALTER TABLE .* ALTER COLUMN .* TYPE", sql),
message=(
"ALTERING columns (Could be backward compatible. "
"You may ignore this migration.)"
),
mode=CheckMode.ONE_LINER,
type=CheckType.ERROR,
),
Check(
code="ADD_UNIQUE",
fn=has_add_unique,
message="ADDING unique constraint",
mode=CheckMode.TRANSACTION,
type=CheckType.ERROR,
),
]
migration_checks: list[Check] = []
def __init__(self, exclude_migration_tests: Iterable[str] | None):
self.exclude_migration_tests: Iterable[str] = exclude_migration_tests or []
self.errors: list[Issue] = []
self.warnings: list[Issue] = []
self.ignored: list[Issue] = []
self.migration_checks = update_migration_checks(
self.base_migration_checks, self.migration_checks
)
def analyse(self, sql_statements: list[str]) -> None:
for statement in sql_statements:
for test in self.one_line_migration_checks:
self._check_sql(test, sql=statement)
for test in self.transaction_migration_checks:
self._check_sql(test, sql=sql_statements)
@property
def one_line_migration_checks(self) -> Iterable[Check]:
return (c for c in self.migration_checks if c.mode == CheckMode.ONE_LINER)
@property
def transaction_migration_checks(self) -> Iterable[Check]:
return (c for c in self.migration_checks if c.mode == CheckMode.TRANSACTION)
def _check_sql(self, check: Check, sql: list[str] | str) -> None:
if check.fn(sql, errors=self.errors):
if check.code in self.exclude_migration_tests:
action = "IGNORED"
list_to_add = self.ignored
elif check.type == CheckType.WARNING:
action = "WARNING"
list_to_add = self.warnings
else:
action = "ERROR"
list_to_add = self.errors
logger.debug("Testing %s -- %s", sql, action)
issue = self.build_issue(migration_check=check, sql_statement=sql)
list_to_add.append(issue)
else:
logger.debug("Testing %s -- PASSED", sql)
def build_issue(
self, migration_check: Check, sql_statement: list[str] | str
) -> Issue:
table = self.detect_table(sql_statement)
col = self.detect_column(sql_statement)
return Issue(
code=migration_check.code,
message=migration_check.message,
table=table,
column=col,
)
@staticmethod
def detect_table(sql: list[str] | str) -> str | None:
if isinstance(sql, str):
regex_result = re.search("TABLE [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result:
return regex_result.group(1)
return None
@staticmethod
def detect_column(sql: list[str] | str) -> str | None:
if isinstance(sql, str):
regex_result = re.search("COLUMN [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result:
return regex_result.group(1)
return None
@@ -0,0 +1,31 @@
from __future__ import annotations
import re
from .base import BaseAnalyser, Check, CheckMode, CheckType
class MySqlAnalyser(BaseAnalyser):
migration_checks: list[Check] = [
Check(
code="ALTER_COLUMN",
fn=lambda sql, **kw: re.search("ALTER TABLE .* MODIFY .* (?!NULL);?$", sql),
message=(
"ALTERING columns (Could be backward compatible. "
"You may ignore this migration.)"
),
mode=CheckMode.ONE_LINER,
type=CheckType.ERROR,
),
]
@staticmethod
def detect_column(sql: list[str] | str) -> str | None:
if isinstance(sql, str):
regex_result = re.search("COLUMN [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result:
return regex_result.group(1)
regex_result = re.search("MODIFY [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result:
return regex_result.group(1)
return None
@@ -0,0 +1,91 @@
from __future__ import annotations
import re
from .base import BaseAnalyser, Check, CheckMode, CheckType
def has_create_index_in_transaction(sql_statements: list[str], **kwargs) -> bool:
"""Return if a migration opens a transaction, acquires EXCLUSIVE lock, then indexes.
Any locks that are obtained after a transaction is opened will not be released
until that transaction either commits or rolls back.
Accordingly, it's extremely important that a migration avoid any long-running
queries while exclusive locks are held.
Building an index is generally a long-running operation. A standard index
creation (that is, avoiding use of `CONCURRENTLY`) can be safe to run on
tables which are small in size and/or read-heavy. (`CREATE INDEX` only
prevents writes to a table; reads are allowed during index creation).
This check is a stricter version of `CREATE_INDEX` -- if a team wishes
to build indices nonconcurrently, it's imperative to be mindful of locks.
"""
if not (sql_statements and sql_statements[0].startswith("BEGIN")):
return False
for i, sql in enumerate(sql_statements):
# If any statements acquire an exclusive lock, complain about index creation
# later.
# Nearly every single `ALTER TABLE` command requires an exclusive lock:
# https://www.postgresql.org/docs/current/sql-altertable.html
# (Most common example is `ALTER TABLE... ADD COLUMN`, then later
# `CREATE INDEX`)
if sql.startswith("ALTER TABLE"):
return has_create_index(sql_statements[i + 1 :], ignore_concurrently=False)
return False
def has_create_index(
sql_statements: list[str], ignore_concurrently: bool = True, **kwargs
) -> bool:
regex_result = None
for sql in sql_statements:
regex_result = re.search(r"CREATE (UNIQUE )?INDEX.*ON (.*) \(", sql)
if ignore_concurrently and re.search("INDEX CONCURRENTLY", sql):
regex_result = None
if regex_result:
break
if not regex_result:
return False
concerned_table = regex_result.group(2)
table_is_added_in_transaction = any(
sql.startswith(f"CREATE TABLE {concerned_table}") for sql in sql_statements
)
return not table_is_added_in_transaction
class PostgresqlAnalyser(BaseAnalyser):
migration_checks: list[Check] = [
Check(
code="CREATE_INDEX",
fn=has_create_index,
message="CREATE INDEX locks table",
mode=CheckMode.TRANSACTION,
type=CheckType.WARNING,
),
Check(
code="CREATE_INDEX_EXCLUSIVE",
fn=has_create_index_in_transaction,
message="CREATE INDEX prolongs transaction, delaying lock release",
mode=CheckMode.TRANSACTION,
type=CheckType.WARNING,
),
Check(
code="DROP_INDEX",
fn=lambda sql, **kw: re.search("DROP INDEX", sql)
and not re.search("INDEX CONCURRENTLY", sql),
message="DROP INDEX locks table",
mode=CheckMode.ONE_LINER,
type=CheckType.WARNING,
),
Check(
code="REINDEX",
fn=lambda sql, **kw: sql.startswith("REINDEX"),
message="REINDEX locks table",
mode=CheckMode.ONE_LINER,
type=CheckType.WARNING,
),
]
@@ -0,0 +1,56 @@
from __future__ import annotations
import re
from .base import BaseAnalyser, Check, CheckMode, CheckType
class SqliteAnalyser(BaseAnalyser):
migration_checks: list[Check] = [
Check(
code="RENAME_TABLE",
fn=lambda sql, **kw: re.search("ALTER TABLE .* RENAME TO", sql)
and "__old" not in sql
and "new__" not in sql,
message="RENAMING tables",
mode=CheckMode.ONE_LINER,
type=CheckType.ERROR,
),
Check(
code="DROP_TABLE",
# TODO: improve to detect that the table names overlap
fn=lambda sql_statements, **kw: any(
sql.startswith("DROP TABLE") for sql in sql_statements
)
and not any(sql.startswith("CREATE TABLE") for sql in sql_statements),
message="DROPPING table",
mode=CheckMode.TRANSACTION,
type=CheckType.ERROR,
),
Check(
code="NOT_NULL",
fn=lambda sql_statements, **kw: any(
re.search("NOT NULL(?! PRIMARY)(?! DEFAULT)", sql)
for sql in sql_statements
)
and any(
re.search("ALTER TABLE .* RENAME TO", sql)
and ("__old" in sql or "new__" in sql)
for sql in sql_statements
),
message="NOT NULL constraint on columns",
mode=CheckMode.TRANSACTION,
type=CheckType.ERROR,
),
]
@staticmethod
def detect_table(sql: list[str] | str) -> str | None:
if isinstance(sql, str):
regex_result = re.search("TABLE [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result:
return regex_result.group(1)
regex_result = re.search("ON [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result:
return regex_result.group(1)
return None
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import os
from importlib import import_module
def split_path(path: str) -> list[str]:
decomposed_path: list[str] = []
while 1:
head, tail = os.path.split(path)
if head == path: # sentinel for absolute paths
decomposed_path.insert(0, head)
break
elif tail == path: # sentinel for relative paths
decomposed_path.insert(0, tail)
break
else:
path = head
decomposed_path.insert(0, tail)
if not decomposed_path[-1]:
decomposed_path = decomposed_path[:-1]
return decomposed_path
def split_migration_path(migration_path: str) -> tuple[str, str]:
from django.db.migrations.loader import MIGRATIONS_MODULE_NAME
decomposed_path = split_path(migration_path)
for i, p in enumerate(decomposed_path):
if p == MIGRATIONS_MODULE_NAME:
return decomposed_path[i - 1], os.path.splitext(decomposed_path[i + 1])[0]
return "", ""
def clean_bytes_to_str(byte_input: bytes) -> str:
return byte_input.decode("utf-8").strip()
def get_migration_abspath(app_label: str, migration_name: str) -> str:
from django.db.migrations.loader import MigrationLoader
module_name, _ = MigrationLoader.migrations_module(app_label)
migration_path = f"{module_name}.{migration_name}"
migration_module = import_module(migration_path)
migration_file = migration_module.__file__
if not migration_file:
raise ValueError("Migration file not found")
if migration_file.endswith(".pyc"):
migration_file = migration_file[:-1]
return migration_file