Migrate from setup.py and setup.cfg to pyproject.toml
This commit is contained in:
1 parent
b1f1dd68bb
commit
0320cf10dd
25 files changed
+85
-85
No files matched your search
@@ -0,0 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .migration_linter import * # noqa
|
||||
from .operations import * # noqa
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user