Add internal classes and add type hints + mypy.

This commit is contained in:
David Wobrock authored and David Wobrock committed 2022-12-26 16:26:24 +01:00
1 parent 847ac1241a
commit 2ee7dac3b3
88 files changed
+603 -340

No files matched your search

+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies
run: python -m pip install tox tox-gh-actions run: python -m pip install tox==3.28.0 tox-gh-actions
- name: Test with tox ${{ matrix.python-version }} - name: Test with tox ${{ matrix.python-version }}
run: tox run: tox
@@ -57,7 +57,7 @@ jobs:
with: with:
python-version: '3.10' python-version: '3.10'
- name: Install dependencies - name: Install dependencies
run: python -m pip install tox run: python -m pip install tox==3.28.0
- name: Lint with tox - name: Lint with tox
run: tox run: tox
env: env:
@@ -94,7 +94,7 @@ jobs:
with: with:
python-version: '3.10' python-version: '3.10'
- name: Install dependencies and coverage - name: Install dependencies and coverage
run: python -m pip install tox run: python -m pip install tox==3.28.0
- name: Test with tox with coverage - name: Test with tox with coverage
run: tox run: tox
env: env:
+3
View File
@@ -9,6 +9,9 @@ The crash is a sign to double-check the migration. But if you are certain the mi
- Add support for Python 3.11 - Add support for Python 3.11
- Add support for Django 4.1 - Add support for Django 4.1
- Drop support for Django 2.2 - Drop support for Django 2.2
- Internally rename "migration tests" to "migration checks"
- Add dataclasses internally instead of custom dicts
- Add type checking using `mypy`
## 4.1.0 ## 4.1.0
+2
View File
@@ -1,2 +1,4 @@
from __future__ import annotations
from .migration_linter import * # noqa from .migration_linter import * # noqa
from .operations import * # noqa from .operations import * # noqa
+5 -3
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import os import os
import pickle import pickle
class Cache(dict): class Cache(dict):
def __init__(self, django_folder, database, cache_path): def __init__(self, django_folder: str | None, database: str, cache_path: str):
self.filename = os.path.join( self.filename = os.path.join(
cache_path, cache_path,
"{}_{}.pickle".format(str(django_folder).replace(os.sep, "_"), database), "{}_{}.pickle".format(str(django_folder).replace(os.sep, "_"), database),
@@ -14,7 +16,7 @@ class Cache(dict):
super().__init__() super().__init__()
def load(self): def load(self) -> None:
try: try:
with open(self.filename, "rb") as f: with open(self.filename, "rb") as f:
tmp_dict = pickle.load(f) tmp_dict = pickle.load(f)
@@ -22,6 +24,6 @@ class Cache(dict):
except OSError: except OSError:
pass pass
def save(self): def save(self) -> None:
with open(self.filename, "wb") as f: with open(self.filename, "wb") as f:
pickle.dump(self, f, protocol=2) pickle.dump(self, f, protocol=2)
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from appdirs import user_cache_dir from appdirs import user_cache_dir
__version__ = "4.1.0" __version__ = "4.1.0"
@@ -1,12 +1,15 @@
from __future__ import annotations
import configparser import configparser
import itertools import itertools
import os import os
import sys import sys
from importlib import import_module from importlib import import_module
from typing import Any, Callable
import toml import toml
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand, CommandParser
from ...constants import __version__ from ...constants import __version__
from ...migration_linter import MessageType, MigrationLinter from ...migration_linter import MessageType, MigrationLinter
@@ -28,7 +31,7 @@ DEFAULT_CONFIG_FILES = (
class Command(BaseCommand): class Command(BaseCommand):
help = "Lint your migrations" help = "Lint your migrations"
def add_arguments(self, parser): def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument( parser.add_argument(
"app_label", "app_label",
nargs="?", nargs="?",
@@ -184,7 +187,7 @@ class Command(BaseCommand):
sys.exit(1) sys.exit(1)
@staticmethod @staticmethod
def read_django_settings(options): def read_django_settings(options: dict[str, Any]) -> dict[str, Any]:
django_settings_options = dict() django_settings_options = dict()
django_migration_linter_settings = getattr( django_migration_linter_settings = getattr(
@@ -197,12 +200,13 @@ class Command(BaseCommand):
return django_settings_options return django_settings_options
@staticmethod @staticmethod
def read_config_file(options): def read_config_file(options: dict[str, Any]) -> dict[str, Any]:
config_options = dict() config_options = dict()
config_parser = configparser.ConfigParser() config_parser = configparser.ConfigParser()
config_parser.read(DEFAULT_CONFIG_FILES, encoding="utf-8") config_parser.read(DEFAULT_CONFIG_FILES, encoding="utf-8")
for key, value in options.items(): for key, value in options.items():
config_get_fn: Callable
if isinstance(value, bool): if isinstance(value, bool):
config_get_fn = config_parser.getboolean config_get_fn = config_parser.getboolean
else: else:
@@ -214,7 +218,7 @@ class Command(BaseCommand):
return config_options return config_options
@staticmethod @staticmethod
def read_toml_file(options): def read_toml_file(options: dict[str, Any]) -> dict[str, Any]:
toml_options = dict() toml_options = dict()
if os.path.exists(PYPROJECT_TOML): if os.path.exists(PYPROJECT_TOML):
@@ -226,5 +230,5 @@ class Command(BaseCommand):
return toml_options return toml_options
def get_version(self): def get_version(self) -> str:
return __version__ return __version__
@@ -1,9 +1,13 @@
from __future__ import annotations
import os import os
from django.conf import settings from django.conf import settings
from django.core.management import CommandParser
from django.core.management.commands.makemigrations import ( from django.core.management.commands.makemigrations import (
Command as MakeMigrationsCommand, Command as MakeMigrationsCommand,
) )
from django.db.migrations import Migration
from django.db.migrations.questioner import InteractiveMigrationQuestioner from django.db.migrations.questioner import InteractiveMigrationQuestioner
from django.db.migrations.writer import MigrationWriter from django.db.migrations.writer import MigrationWriter
@@ -16,7 +20,7 @@ from ..utils import (
) )
def ask_should_keep_migration(): def ask_should_keep_migration() -> bool:
questioner = InteractiveMigrationQuestioner() questioner = InteractiveMigrationQuestioner()
msg = """ msg = """
The migration linter detected that this migration is not backward compatible. The migration linter detected that this migration is not backward compatible.
@@ -33,7 +37,7 @@ def default_should_keep_migration():
class Command(MakeMigrationsCommand): class Command(MakeMigrationsCommand):
help = "Creates new migration(s) for apps and lints them." help = "Creates new migration(s) for apps and lints them."
def add_arguments(self, parser): def add_arguments(self, parser: CommandParser) -> None:
super().add_arguments(parser) super().add_arguments(parser)
parser.add_argument( parser.add_argument(
"--lint", "--lint",
@@ -51,7 +55,7 @@ class Command(MakeMigrationsCommand):
configure_logging(options["verbosity"]) configure_logging(options["verbosity"])
return super().handle(*app_labels, **options) return super().handle(*app_labels, **options)
def write_migration_files(self, changes): def write_migration_files(self, changes: dict[str, list[Migration]]) -> None:
super().write_migration_files(changes) super().write_migration_files(changes)
if ( if (
@@ -79,7 +83,7 @@ class Command(MakeMigrationsCommand):
all_warnings_as_errors, all_warnings_as_errors,
) = extract_warnings_as_errors_option(self.warnings_as_errors) ) = extract_warnings_as_errors_option(self.warnings_as_errors)
# Lint migrations # Lint migrations.
linter = MigrationLinter( linter = MigrationLinter(
path=os.environ["DJANGO_SETTINGS_MODULE"], path=os.environ["DJANGO_SETTINGS_MODULE"],
database=self.database, database=self.database,
@@ -103,7 +107,7 @@ class Command(MakeMigrationsCommand):
self.delete_migration(migration) self.delete_migration(migration)
linter.reset_counters() linter.reset_counters()
def delete_migration(self, migration): def delete_migration(self, migration: Migration) -> None:
writer = MigrationWriter(migration) writer = MigrationWriter(migration)
os.remove(writer.path) os.remove(writer.path)
+9 -3
View File
@@ -1,9 +1,13 @@
from __future__ import annotations
import logging import logging
from django.core.management import CommandParser
from ..sql_analyser.analyser import ANALYSER_STRING_MAPPING from ..sql_analyser.analyser import ANALYSER_STRING_MAPPING
def register_linting_configuration_options(parser): def register_linting_configuration_options(parser: CommandParser) -> None:
parser.add_argument( parser.add_argument(
"--database", "--database",
type=str, type=str,
@@ -37,7 +41,7 @@ def register_linting_configuration_options(parser):
) )
def configure_logging(verbosity): def configure_logging(verbosity: int) -> None:
logger = logging.getLogger("django_migration_linter") logger = logging.getLogger("django_migration_linter")
if verbosity > 1: if verbosity > 1:
@@ -48,7 +52,9 @@ def configure_logging(verbosity):
logging.basicConfig(format="%(message)s") logging.basicConfig(format="%(message)s")
def extract_warnings_as_errors_option(warnings_as_errors): def extract_warnings_as_errors_option(
warnings_as_errors: list[str] | None,
) -> tuple[list[str] | None, bool]:
if isinstance(warnings_as_errors, list): if isinstance(warnings_as_errors, list):
warnings_as_errors_tests = warnings_as_errors warnings_as_errors_tests = warnings_as_errors
# If the option is specified but without any test codes, # If the option is specified but without any test codes,
+116 -89
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import hashlib import hashlib
import inspect import inspect
import logging import logging
@@ -5,11 +7,13 @@ import os
import re import re
from enum import Enum, unique from enum import Enum, unique
from subprocess import PIPE, Popen from subprocess import PIPE, Popen
from typing import Callable, Iterable
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connections from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connections
from django.db.migrations import RunPython, RunSQL from django.db.migrations import Migration, RunPython, RunSQL
from django.db.migrations.operations.base import Operation
from .cache import Cache from .cache import Cache
from .constants import ( from .constants import (
@@ -19,6 +23,7 @@ from .constants import (
) )
from .operations import IgnoreMigration from .operations import IgnoreMigration
from .sql_analyser import analyse_sql_statements, get_sql_analyser_class 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 from .utils import clean_bytes_to_str, get_migration_abspath, split_migration_path
logger = logging.getLogger("django_migration_linter") logger = logging.getLogger("django_migration_linter")
@@ -32,38 +37,38 @@ class MessageType(Enum):
ERROR = "error" ERROR = "error"
@staticmethod @staticmethod
def values(): def values() -> list[str]:
return list(map(lambda c: c.value, MessageType)) return list(map(lambda c: c.value, MessageType))
class MigrationLinter: class MigrationLinter:
def __init__( def __init__(
self, self,
path=None, path: str | None = None,
ignore_name_contains=None, ignore_name_contains: str | None = None,
ignore_name=None, ignore_name: Iterable[str] | None = None,
include_name_contains=None, include_name_contains: str | None = None,
include_name=None, include_name: Iterable[str] | None = None,
include_apps=None, include_apps: Iterable[str] | None = None,
exclude_apps=None, exclude_apps: Iterable[str] | None = None,
database=DEFAULT_DB_ALIAS, database: str = DEFAULT_DB_ALIAS,
cache_path=DEFAULT_CACHE_PATH, cache_path: str = DEFAULT_CACHE_PATH,
no_cache=False, no_cache: bool = False,
only_applied_migrations=False, only_applied_migrations: bool = False,
only_unapplied_migrations=False, only_unapplied_migrations: bool = False,
exclude_migration_tests=None, exclude_migration_tests: Iterable[str] | None = None,
quiet=None, quiet: Iterable[str] | None = None,
warnings_as_errors_tests=None, warnings_as_errors_tests: Iterable[str] | None = None,
all_warnings_as_errors=False, all_warnings_as_errors: bool = False,
no_output=False, no_output: bool = False,
analyser_string=None, analyser_string: str | None = None,
): ):
# Store parameters and options # Store parameters and options
self.django_path = path self.django_path = path
self.ignore_name_contains = ignore_name_contains self.ignore_name_contains = ignore_name_contains
self.ignore_name = ignore_name or tuple() self.ignore_name = ignore_name or []
self.include_name_contains = include_name_contains self.include_name_contains = include_name_contains
self.include_name = include_name or tuple() self.include_name = include_name or []
self.include_apps = include_apps self.include_apps = include_apps
self.exclude_apps = exclude_apps self.exclude_apps = exclude_apps
self.exclude_migration_tests = exclude_migration_tests or [] self.exclude_migration_tests = exclude_migration_tests or []
@@ -84,7 +89,7 @@ class MigrationLinter:
# Initialise counters # Initialise counters
self.reset_counters() self.reset_counters()
# Initialise cache. Read from old, write to new to prune old entries. # Initialise cache. Read from old, write to new, in order to prune old entries.
if self.should_use_cache(): if self.should_use_cache():
self.old_cache = Cache(self.django_path, self.database, self.cache_path) 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.new_cache = Cache(self.django_path, self.database, self.cache_path)
@@ -97,31 +102,31 @@ class MigrationLinter:
connection=connections[self.database], load=True connection=connections[self.database], load=True
) )
def reset_counters(self): def reset_counters(self) -> None:
self.nb_valid = 0 self.nb_valid = 0
self.nb_ignored = 0 self.nb_ignored = 0
self.nb_warnings = 0 self.nb_warnings = 0
self.nb_erroneous = 0 self.nb_erroneous = 0
self.nb_total = 0 self.nb_total = 0
def should_use_cache(self): def should_use_cache(self) -> bool:
return self.django_path and not self.no_cache return bool(self.django_path and not self.no_cache)
def lint_all_migrations( def lint_all_migrations(
self, self,
app_label=None, app_label: str | None = None,
migration_name=None, migration_name: str | None = None,
git_commit_id=None, git_commit_id: str | None = None,
migrations_file_path=None, migrations_file_path: str | None = None,
): ) -> None:
# Collect migrations # Collect migrations.
migrations_list = self.read_migrations_list(migrations_file_path) migrations_list = self.read_migrations_list(migrations_file_path)
if git_commit_id: if git_commit_id:
migrations = self._gather_migrations_git(git_commit_id, migrations_list) migrations = self._gather_migrations_git(git_commit_id, migrations_list)
else: else:
migrations = self._gather_all_migrations(migrations_list) migrations = self._gather_all_migrations(migrations_list)
# Lint those migrations # Lint those migrations.
sorted_migrations = sorted( sorted_migrations = sorted(
migrations, key=lambda migration: (migration.app_label, migration.name) migrations, key=lambda migration: (migration.app_label, migration.name)
) )
@@ -145,7 +150,7 @@ class MigrationLinter:
if self.should_use_cache(): if self.should_use_cache():
self.new_cache.save() self.new_cache.save()
def lint_migration(self, migration): def lint_migration(self, migration: Migration) -> None:
app_label = migration.app_label app_label = migration.app_label
migration_name = migration.name migration_name = migration.name
operations = migration.operations operations = migration.operations
@@ -185,7 +190,7 @@ class MigrationLinter:
elif self.warnings_as_errors_tests: elif self.warnings_as_errors_tests:
new_warnings = [] new_warnings = []
for w in warnings: for w in warnings:
if w["code"] in self.warnings_as_errors_tests: if w.code in self.warnings_as_errors_tests:
errors.append(w) errors.append(w)
else: else:
new_warnings.append(w) new_warnings.append(w)
@@ -222,14 +227,16 @@ class MigrationLinter:
self.new_cache[md5hash] = value_to_cache self.new_cache[md5hash] = value_to_cache
@staticmethod @staticmethod
def get_migration_hash(app_label, migration_name): def get_migration_hash(app_label: str, migration_name: str) -> str:
hash_md5 = hashlib.md5() hash_md5 = hashlib.md5()
with open(get_migration_abspath(app_label, migration_name), "rb") as f: with open(get_migration_abspath(app_label, migration_name), "rb") as f:
for chunk in iter(lambda: f.read(4096), b""): for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk) hash_md5.update(chunk)
return hash_md5.hexdigest() return hash_md5.hexdigest()
def lint_cached_migration(self, app_label, migration_name, md5hash): def lint_cached_migration(
self, app_label: str, migration_name: str, md5hash: str
) -> None:
cached_value = self.old_cache[md5hash] cached_value = self.old_cache[md5hash]
if cached_value["result"] == "IGNORE": if cached_value["result"] == "IGNORE":
self.print_linting_msg( self.print_linting_msg(
@@ -259,35 +266,37 @@ class MigrationLinter:
self.new_cache[md5hash] = cached_value self.new_cache[md5hash] = cached_value
def print_linting_msg(self, app_label, migration_name, msg, lint_result): def print_linting_msg(
self, app_label: str, migration_name: str, msg: str, lint_result: MessageType
) -> None:
if lint_result.value in self.quiet: if lint_result.value in self.quiet:
return return
if not self.no_output: if not self.no_output:
print(f"({app_label}, {migration_name})... {msg}") print(f"({app_label}, {migration_name})... {msg}")
def print_errors(self, errors): def print_errors(self, errors: list[Issue]) -> None:
if MessageType.ERROR.value in self.quiet: if MessageType.ERROR.value in self.quiet:
return return
for err in errors: for err in errors:
error_str = "\t{}".format(err["msg"]) error_str = "\t{}".format(err.message)
if err.get("table"): if err.table:
error_str += " (table: {}".format(err["table"]) error_str += " (table: {}".format(err.table)
if err.get("column"): if err.column:
error_str += ", column: {}".format(err["column"]) error_str += ", column: {}".format(err.column)
error_str += ")" error_str += ")"
if not self.no_output: if not self.no_output:
print(error_str) print(error_str)
def print_warnings(self, warnings): def print_warnings(self, warnings: list[Issue]) -> None:
if MessageType.WARNING.value in self.quiet: if MessageType.WARNING.value in self.quiet:
return return
for warning_details in warnings: for warning_details in warnings:
warn_str = "\t{}".format(warning_details["msg"]) warn_str = "\t{}".format(warning_details.message)
if not self.no_output: if not self.no_output:
print(warn_str) print(warn_str)
def print_summary(self): def print_summary(self) -> None:
if self.no_output: if self.no_output:
return return
print("*** Summary ***") print("*** Summary ***")
@@ -297,10 +306,10 @@ class MigrationLinter:
print(f"Ignored migrations: {self.nb_ignored}/{self.nb_total}") print(f"Ignored migrations: {self.nb_ignored}/{self.nb_total}")
@property @property
def has_errors(self): def has_errors(self) -> bool:
return self.nb_erroneous > 0 return self.nb_erroneous > 0
def get_sql(self, app_label, migration_name): def get_sql(self, app_label: str, migration_name: str) -> list[str]:
logger.info(f"Calling sqlmigrate command {app_label} {migration_name}") logger.info(f"Calling sqlmigrate command {app_label} {migration_name}")
try: try:
with open(os.devnull, "w") as dev_null: with open(os.devnull, "w") as dev_null:
@@ -321,16 +330,18 @@ class MigrationLinter:
return sql_statement.splitlines() return sql_statement.splitlines()
@staticmethod @staticmethod
def is_migration_file(filename): def is_migration_file(filename: str) -> bool:
from django.db.migrations.loader import MIGRATIONS_MODULE_NAME from django.db.migrations.loader import MIGRATIONS_MODULE_NAME
return ( return bool(
re.search(rf"/{MIGRATIONS_MODULE_NAME}/.*\.py", filename) re.search(rf"/{MIGRATIONS_MODULE_NAME}/.*\.py", filename)
and "__init__" not in filename and "__init__" not in filename
) )
@classmethod @classmethod
def read_migrations_list(cls, migrations_file_path): 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. Returning an empty list is different from returning None here.
None: no file was specified and we should consider all migrations None: no file was specified and we should consider all migrations
@@ -357,7 +368,9 @@ class MigrationLinter:
) )
return migrations return migrations
def _gather_migrations_git(self, git_commit_id, migrations_list=None): def _gather_migrations_git(
self, git_commit_id: str, migrations_list: list[tuple[str, str]] | None = None
) -> Iterable[Migration]:
migrations = [] migrations = []
# Get changes since specified commit # Get changes since specified commit
git_diff_command = ( git_diff_command = (
@@ -365,7 +378,9 @@ class MigrationLinter:
).format(self.django_path, git_commit_id) ).format(self.django_path, git_commit_id)
logger.info(f"Executing {git_diff_command}") logger.info(f"Executing {git_diff_command}")
diff_process = Popen(git_diff_command, shell=True, stdout=PIPE, stderr=PIPE) diff_process = Popen(git_diff_command, shell=True, stdout=PIPE, stderr=PIPE)
for line in map(clean_bytes_to_str, diff_process.stdout.readlines()): for line in map(
clean_bytes_to_str, diff_process.stdout.readlines() # type: ignore
):
# Only gather lines that include added migrations # Only gather lines that include added migrations
if self.is_migration_file(line): if self.is_migration_file(line):
app_label, name = split_migration_path(line) app_label, name = split_migration_path(line)
@@ -386,13 +401,17 @@ class MigrationLinter:
if diff_process.returncode != 0: if diff_process.returncode != 0:
output = [] output = []
for line in map(clean_bytes_to_str, diff_process.stderr.readlines()): for line in map(
clean_bytes_to_str, diff_process.stderr.readlines() # type: ignore
):
output.append(line) output.append(line)
logger.error("Error while git diff command:\n{}".format("".join(output))) logger.error("Error while git diff command:\n{}".format("".join(output)))
raise Exception("Error while executing git diff command") raise Exception("Error while executing git diff command")
return migrations return migrations
def _gather_all_migrations(self, migrations_list=None): def _gather_all_migrations(
self, migrations_list: list[tuple[str, str]] | None = None
) -> Iterable[Migration]:
for ( for (
(app_label, name), (app_label, name),
migration, migration,
@@ -401,7 +420,9 @@ class MigrationLinter:
if migrations_list is None or (app_label, name) in migrations_list: if migrations_list is None or (app_label, name) in migrations_list:
yield migration yield migration
def should_ignore_migration(self, app_label, migration_name, operations=()): def should_ignore_migration(
self, app_label: str, migration_name: str, operations: Iterable[Operation] = ()
) -> bool:
return ( return (
(self.include_apps and app_label not in self.include_apps) (self.include_apps and app_label not in self.include_apps)
or (self.exclude_apps and app_label in self.exclude_apps) or (self.exclude_apps and app_label in self.exclude_apps)
@@ -428,7 +449,9 @@ class MigrationLinter:
) )
) )
def analyse_data_migration(self, migration): def analyse_data_migration(
self, migration: Migration
) -> tuple[list[Issue], list[Issue], list[Issue]]:
errors = [] errors = []
ignored = [] ignored = []
warnings = [] warnings = []
@@ -450,7 +473,9 @@ class MigrationLinter:
return errors, ignored, warnings return errors, ignored, warnings
def lint_runpython(self, runpython): def lint_runpython(
self, runpython: RunPython
) -> tuple[list[Issue], list[Issue], list[Issue]]:
function_name = runpython.code.__name__ function_name = runpython.code.__name__
error = [] error = []
ignored = [] ignored = []
@@ -458,13 +483,13 @@ class MigrationLinter:
# Detect warning on missing reverse operation # Detect warning on missing reverse operation
if not runpython.reversible: if not runpython.reversible:
issue = { issue = Issue(
"code": "RUNPYTHON_REVERSIBLE", code="RUNPYTHON_REVERSIBLE",
"msg": "'{}': RunPython data migration is not reversible".format( message="'{}': RunPython data migration is not reversible".format(
function_name function_name
), ),
} )
if issue["code"] in self.exclude_migration_tests: if issue.code in self.exclude_migration_tests:
ignored.append(issue) ignored.append(issue)
else: else:
warning.append(issue) warning.append(issue)
@@ -472,14 +497,14 @@ class MigrationLinter:
# Detect warning for argument naming convention # Detect warning for argument naming convention
args_spec = inspect.getfullargspec(runpython.code) args_spec = inspect.getfullargspec(runpython.code)
if tuple(args_spec.args) != EXPECTED_DATA_MIGRATION_ARGS: if tuple(args_spec.args) != EXPECTED_DATA_MIGRATION_ARGS:
issue = { issue = Issue(
"code": "RUNPYTHON_ARGS_NAMING_CONVENTION", code="RUNPYTHON_ARGS_NAMING_CONVENTION",
"msg": ( message=(
"'{}': By convention, " "'{}': By convention, "
"RunPython names the two arguments: apps, schema_editor" "RunPython names the two arguments: apps, schema_editor"
).format(function_name), ).format(function_name),
} )
if issue["code"] in self.exclude_migration_tests: if issue.code in self.exclude_migration_tests:
ignored.append(issue) ignored.append(issue)
else: else:
warning.append(issue) warning.append(issue)
@@ -488,7 +513,7 @@ class MigrationLinter:
# Forward # Forward
issues = self.get_runpython_model_import_issues(runpython.code) issues = self.get_runpython_model_import_issues(runpython.code)
for issue in issues: for issue in issues:
if issue["code"] in self.exclude_migration_tests: if issue.code in self.exclude_migration_tests:
ignored.append(issue) ignored.append(issue)
else: else:
error.append(issue) error.append(issue)
@@ -497,7 +522,7 @@ class MigrationLinter:
if runpython.reversible: if runpython.reversible:
issues = self.get_runpython_model_import_issues(runpython.reverse_code) issues = self.get_runpython_model_import_issues(runpython.reverse_code)
for issue in issues: for issue in issues:
if issue and issue["code"] in self.exclude_migration_tests: if issue and issue.code in self.exclude_migration_tests:
ignored.append(issue) ignored.append(issue)
else: else:
error.append(issue) error.append(issue)
@@ -505,7 +530,7 @@ class MigrationLinter:
# Detect warning if model variable name is not the same as model class # Detect warning if model variable name is not the same as model class
issues = self.get_runpython_model_variable_naming_issues(runpython.code) issues = self.get_runpython_model_variable_naming_issues(runpython.code)
for issue in issues: for issue in issues:
if issue and issue["code"] in self.exclude_migration_tests: if issue and issue.code in self.exclude_migration_tests:
ignored.append(issue) ignored.append(issue)
else: else:
warning.append(issue) warning.append(issue)
@@ -515,7 +540,7 @@ class MigrationLinter:
runpython.reverse_code runpython.reverse_code
) )
for issue in issues: for issue in issues:
if issue and issue["code"] in self.exclude_migration_tests: if issue and issue.code in self.exclude_migration_tests:
ignored.append(issue) ignored.append(issue)
else: else:
warning.append(issue) warning.append(issue)
@@ -523,7 +548,7 @@ class MigrationLinter:
return error, ignored, warning return error, ignored, warning
@staticmethod @staticmethod
def get_runpython_model_import_issues(code): 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") model_object_regex = re.compile(r"[^a-zA-Z0-9._]?([a-zA-Z0-9._]+?)\.objects")
function_name = code.__name__ function_name = code.__name__
@@ -542,19 +567,19 @@ class MigrationLinter:
) )
if not has_get_model_call: if not has_get_model_call:
issues.append( issues.append(
{ Issue(
"code": "RUNPYTHON_MODEL_IMPORT", code="RUNPYTHON_MODEL_IMPORT",
"msg": ( message=(
"'{}': Could not find an 'apps.get_model(\"...\", \"{}\")' " "'{}': Could not find an 'apps.get_model(\"...\", \"{}\")' "
"call. Importing the model directly is incorrect for " "call. Importing the model directly is incorrect for "
"data migrations." "data migrations."
).format(function_name, model), ).format(function_name, model),
} )
) )
return issues return issues
@staticmethod @staticmethod
def get_runpython_model_variable_naming_issues(code): 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") model_object_regex = re.compile(r"[^a-zA-Z]?([a-zA-Z0-9]+?)\.objects")
function_name = code.__name__ function_name = code.__name__
@@ -583,29 +608,31 @@ class MigrationLinter:
) )
if not has_same_model_name: if not has_same_model_name:
issues.append( issues.append(
{ Issue(
"code": "RUNPYTHON_MODEL_VARIABLE_NAME", code="RUNPYTHON_MODEL_VARIABLE_NAME",
"msg": ( message=(
"'{}': Model variable name {} is different from the " "'{}': Model variable name {} is different from the "
"model class name that was found in the " "model class name that was found in the "
"apps.get_model(...) call." "apps.get_model(...) call."
).format(function_name, model), ).format(function_name, model),
} )
) )
return issues return issues
def lint_runsql(self, runsql): def lint_runsql(
self, runsql: RunSQL
) -> tuple[list[Issue], list[Issue], list[Issue]]:
error = [] error = []
ignored = [] ignored = []
warning = [] warning = []
# Detect warning on missing reverse operation # Detect warning on missing reverse operation
if not runsql.reversible: if not runsql.reversible:
issue = { issue = Issue(
"code": "RUNSQL_REVERSIBLE", code="RUNSQL_REVERSIBLE",
"msg": "RunSQL data migration is not reversible", message="RunSQL data migration is not reversible",
} )
if issue["code"] in self.exclude_migration_tests: if issue.code in self.exclude_migration_tests:
ignored.append(issue) ignored.append(issue)
else: else:
warning.append(issue) warning.append(issue)
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db.migrations.operations.base import Operation from django.db.migrations.operations.base import Operation
@@ -1,3 +1,5 @@
from __future__ import annotations
from .base import BaseAnalyser # noqa from .base import BaseAnalyser # noqa
from .mysql import MySqlAnalyser # noqa from .mysql import MySqlAnalyser # noqa
from .postgresql import PostgresqlAnalyser # noqa from .postgresql import PostgresqlAnalyser # noqa
@@ -1,6 +1,13 @@
from __future__ import annotations
import logging 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 ( from django_migration_linter.sql_analyser import (
BaseAnalyser,
MySqlAnalyser, MySqlAnalyser,
PostgresqlAnalyser, PostgresqlAnalyser,
SqliteAnalyser, SqliteAnalyser,
@@ -8,20 +15,22 @@ from django_migration_linter.sql_analyser import (
logger = logging.getLogger("django_migration_linter") logger = logging.getLogger("django_migration_linter")
ANALYSER_STRING_MAPPING = { ANALYSER_STRING_MAPPING: dict[str, Type[BaseAnalyser]] = {
"sqlite": SqliteAnalyser, "sqlite": SqliteAnalyser,
"mysql": MySqlAnalyser, "mysql": MySqlAnalyser,
"postgresql": PostgresqlAnalyser, "postgresql": PostgresqlAnalyser,
} }
def get_sql_analyser_class(database_vendor, analyser_string=None): def get_sql_analyser_class(
database_vendor: str, analyser_string: str | None = None
) -> Type[BaseAnalyser]:
if analyser_string: if analyser_string:
return get_sql_analyser_from_string(analyser_string) return get_sql_analyser_from_string(analyser_string)
return get_sql_analyser_class_from_db_vendor(database_vendor) return get_sql_analyser_class_from_db_vendor(database_vendor)
def get_sql_analyser_from_string(analyser_string): def get_sql_analyser_from_string(analyser_string: str) -> Type[BaseAnalyser]:
if analyser_string not in ANALYSER_STRING_MAPPING: if analyser_string not in ANALYSER_STRING_MAPPING:
raise ValueError( raise ValueError(
"Unknown SQL analyser '{}'. Known values: '{}'".format( "Unknown SQL analyser '{}'. Known values: '{}'".format(
@@ -32,7 +41,8 @@ def get_sql_analyser_from_string(analyser_string):
return ANALYSER_STRING_MAPPING[analyser_string] return ANALYSER_STRING_MAPPING[analyser_string]
def get_sql_analyser_class_from_db_vendor(database_vendor): def get_sql_analyser_class_from_db_vendor(database_vendor: str) -> Type[BaseAnalyser]:
sql_analyser_class: Type[BaseAnalyser]
if "mysql" in database_vendor: if "mysql" in database_vendor:
sql_analyser_class = MySqlAnalyser sql_analyser_class = MySqlAnalyser
elif "postgre" in database_vendor: elif "postgre" in database_vendor:
@@ -51,8 +61,10 @@ def get_sql_analyser_class_from_db_vendor(database_vendor):
def analyse_sql_statements( def analyse_sql_statements(
sql_analyser_class, sql_statements, exclude_migration_tests=None 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 = sql_analyser_class(exclude_migration_tests)
sql_analyser.analyse(sql_statements) sql_analyser.analyse(sql_statements)
return sql_analyser.errors, sql_analyser.ignored, sql_analyser.warnings return sql_analyser.errors, sql_analyser.ignored, sql_analyser.warnings
+149 -88
View File
@@ -1,12 +1,36 @@
from __future__ import annotations
import logging import logging
import re import re
from copy import deepcopy
from .utils import update_migration_tests from dataclasses import dataclass
from enum import Enum
from typing import Callable, Iterable
logger = logging.getLogger("django_migration_linter") logger = logging.getLogger("django_migration_linter")
def has_not_null_column(sql_statements, **kwargs): 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 # TODO: improve to detect that the same column is concerned
ends_with_default = False ends_with_default = False
for sql in sql_statements: for sql in sql_statements:
@@ -23,7 +47,7 @@ def has_not_null_column(sql_statements, **kwargs):
) )
def has_add_unique(sql_statements, **kwargs): def has_add_unique(sql_statements: list[str], **kwargs) -> bool:
regex_result = None regex_result = None
for sql in sql_statements: for sql in sql_statements:
regex_result = re.search( regex_result = re.search(
@@ -41,130 +65,167 @@ def has_add_unique(sql_statements, **kwargs):
return not table_is_added_in_transaction 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: class BaseAnalyser:
base_migration_tests = [ base_migration_checks: list[Check] = [
{ Check(
"code": "RENAME_TABLE", code="RENAME_TABLE",
"fn": lambda sql, **kw: re.search("RENAME TABLE", sql) fn=lambda sql, **kw: re.search("RENAME TABLE", sql)
or re.search("ALTER TABLE .* RENAME TO", sql), or re.search("ALTER TABLE .* RENAME TO", sql),
"msg": "RENAMING tables", message="RENAMING tables",
"mode": "one_liner", mode=CheckMode.ONE_LINER,
"type": "error", type=CheckType.ERROR,
}, ),
{ Check(
"code": "NOT_NULL", code="NOT_NULL",
"fn": has_not_null_column, fn=has_not_null_column,
"msg": "NOT NULL constraint on columns", message="NOT NULL constraint on columns",
"mode": "transaction", mode=CheckMode.TRANSACTION,
"type": "error", type=CheckType.ERROR,
}, ),
{ Check(
"code": "DROP_COLUMN", code="DROP_COLUMN",
"fn": lambda sql, **kw: re.search("DROP COLUMN", sql), fn=lambda sql, **kw: re.search("DROP COLUMN", sql),
"msg": "DROPPING columns", message="DROPPING columns",
"mode": "one_liner", mode=CheckMode.ONE_LINER,
"type": "error", type=CheckType.ERROR,
}, ),
{ Check(
"code": "DROP_TABLE", code="DROP_TABLE",
"fn": lambda sql, **kw: sql.startswith("DROP TABLE"), fn=lambda sql, **kw: sql.startswith("DROP TABLE"),
"msg": "DROPPING table", message="DROPPING table",
"mode": "one_liner", mode=CheckMode.ONE_LINER,
"type": "error", type=CheckType.ERROR,
}, ),
{ Check(
"code": "RENAME_COLUMN", code="RENAME_COLUMN",
"fn": lambda sql, **kw: re.search("ALTER TABLE .* CHANGE", sql) fn=lambda sql, **kw: re.search("ALTER TABLE .* CHANGE", sql)
or re.search("ALTER TABLE .* RENAME COLUMN", sql), or re.search("ALTER TABLE .* RENAME COLUMN", sql),
"msg": "RENAMING columns", message="RENAMING columns",
"mode": "one_liner", mode=CheckMode.ONE_LINER,
"type": "error", type=CheckType.ERROR,
}, ),
{ Check(
"code": "ALTER_COLUMN", code="ALTER_COLUMN",
"fn": lambda sql, **kw: re.search( fn=lambda sql, **kw: re.search("ALTER TABLE .* ALTER COLUMN .* TYPE", sql),
"ALTER TABLE .* ALTER COLUMN .* TYPE", sql message=(
),
"msg": (
"ALTERING columns (Could be backward compatible. " "ALTERING columns (Could be backward compatible. "
"You may ignore this migration.)" "You may ignore this migration.)"
), ),
"mode": "one_liner", mode=CheckMode.ONE_LINER,
"type": "error", type=CheckType.ERROR,
}, ),
{ Check(
"code": "ADD_UNIQUE", code="ADD_UNIQUE",
"fn": has_add_unique, fn=has_add_unique,
"msg": "ADDING unique constraint", message="ADDING unique constraint",
"mode": "transaction", mode=CheckMode.TRANSACTION,
"type": "error", type=CheckType.ERROR,
}, ),
] ]
migration_tests = [] migration_checks: list[Check] = []
def __init__(self, exclude_migration_tests): def __init__(self, exclude_migration_tests: Iterable[str] | None):
self.exclude_migration_tests = exclude_migration_tests or [] self.exclude_migration_tests: Iterable[str] = exclude_migration_tests or []
self.errors = [] self.errors: list[Issue] = []
self.warnings = [] self.warnings: list[Issue] = []
self.ignored = [] self.ignored: list[Issue] = []
self.migration_tests = update_migration_tests( self.migration_checks = update_migration_checks(
self.base_migration_tests, self.migration_tests self.base_migration_checks, self.migration_checks
) )
def analyse(self, sql_statements): def analyse(self, sql_statements: list[str]) -> None:
for statement in sql_statements: for statement in sql_statements:
for test in self.one_line_migration_tests: for test in self.one_line_migration_checks:
self._test_sql(test, sql=statement) self._check_sql(test, sql=statement)
for test in self.transaction_migration_tests: for test in self.transaction_migration_checks:
self._test_sql(test, sql=sql_statements) self._check_sql(test, sql=sql_statements)
@property @property
def one_line_migration_tests(self): def one_line_migration_checks(self) -> Iterable[Check]:
return (test for test in self.migration_tests if test["mode"] == "one_liner") return (c for c in self.migration_checks if c.mode == CheckMode.ONE_LINER)
@property @property
def transaction_migration_tests(self): def transaction_migration_checks(self) -> Iterable[Check]:
return (test for test in self.migration_tests if test["mode"] == "transaction") return (c for c in self.migration_checks if c.mode == CheckMode.TRANSACTION)
def _test_sql(self, test, sql): def _check_sql(self, check: Check, sql: list[str] | str) -> None:
if test["fn"](sql, errors=self.errors): if check.fn(sql, errors=self.errors):
if test["code"] in self.exclude_migration_tests: if check.code in self.exclude_migration_tests:
action = "IGNORED" action = "IGNORED"
list_to_add = self.ignored list_to_add = self.ignored
elif test["type"] == "warning": elif check.type == CheckType.WARNING:
action = "WARNING" action = "WARNING"
list_to_add = self.warnings list_to_add = self.warnings
else: else:
action = "ERROR" action = "ERROR"
list_to_add = self.errors list_to_add = self.errors
logger.debug("Testing %s -- %s", sql, action) logger.debug("Testing %s -- %s", sql, action)
err = self.build_error_dict(migration_test=test, sql_statement=sql) issue = self.build_issue(migration_check=check, sql_statement=sql)
list_to_add.append(err) list_to_add.append(issue)
else: else:
logger.debug("Testing %s -- PASSED", sql) logger.debug("Testing %s -- PASSED", sql)
def build_error_dict(self, migration_test, sql_statement): def build_issue(
self, migration_check: Check, sql_statement: list[str] | str
) -> Issue:
table = self.detect_table(sql_statement) table = self.detect_table(sql_statement)
col = self.detect_column(sql_statement) col = self.detect_column(sql_statement)
return { return Issue(
"msg": migration_test["msg"], code=migration_check.code,
"code": migration_test["code"], message=migration_check.message,
"table": table, table=table,
"column": col, column=col,
} )
@staticmethod @staticmethod
def detect_table(sql): def detect_table(sql: list[str] | str) -> str | None:
if isinstance(sql, str): if isinstance(sql, str):
regex_result = re.search("TABLE [`\"'](.*?)[`\"']", sql, re.IGNORECASE) regex_result = re.search("TABLE [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result: if regex_result:
return regex_result.group(1) return regex_result.group(1)
return None
@staticmethod @staticmethod
def detect_column(sql): def detect_column(sql: list[str] | str) -> str | None:
if isinstance(sql, str): if isinstance(sql, str):
regex_result = re.search("COLUMN [`\"'](.*?)[`\"']", sql, re.IGNORECASE) regex_result = re.search("COLUMN [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result: if regex_result:
return regex_result.group(1) return regex_result.group(1)
return None
+15 -10
View File
@@ -1,22 +1,26 @@
from __future__ import annotations
import re import re
from .base import BaseAnalyser from .base import BaseAnalyser, Check, CheckMode, CheckType
class MySqlAnalyser(BaseAnalyser): class MySqlAnalyser(BaseAnalyser):
migration_tests = [ migration_checks: list[Check] = [
{ Check(
"code": "ALTER_COLUMN", code="ALTER_COLUMN",
"fn": lambda sql, **kw: re.search( fn=lambda sql, **kw: re.search("ALTER TABLE .* MODIFY .* (?!NULL);?$", sql),
"ALTER TABLE .* MODIFY .* (?!NULL);?$", sql message=(
"ALTERING columns (Could be backward compatible. "
"You may ignore this migration.)"
), ),
"mode": "one_liner", mode=CheckMode.ONE_LINER,
"type": "error", type=CheckType.ERROR,
} ),
] ]
@staticmethod @staticmethod
def detect_column(sql): def detect_column(sql: list[str] | str) -> str | None:
if isinstance(sql, str): if isinstance(sql, str):
regex_result = re.search("COLUMN [`\"'](.*?)[`\"']", sql, re.IGNORECASE) regex_result = re.search("COLUMN [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result: if regex_result:
@@ -24,3 +28,4 @@ class MySqlAnalyser(BaseAnalyser):
regex_result = re.search("MODIFY [`\"'](.*?)[`\"']", sql, re.IGNORECASE) regex_result = re.search("MODIFY [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result: if regex_result:
return regex_result.group(1) return regex_result.group(1)
return None
@@ -1,9 +1,11 @@
from __future__ import annotations
import re import re
from .base import BaseAnalyser from .base import BaseAnalyser, Check, CheckMode, CheckType
def has_create_index(sql_statements, **kwargs): def has_create_index(sql_statements: list[str], **kwargs) -> bool:
regex_result = None regex_result = None
for sql in sql_statements: for sql in sql_statements:
regex_result = re.search(r"CREATE (UNIQUE )?INDEX.*ON (.*) \(", sql) regex_result = re.search(r"CREATE (UNIQUE )?INDEX.*ON (.*) \(", sql)
@@ -22,27 +24,27 @@ def has_create_index(sql_statements, **kwargs):
class PostgresqlAnalyser(BaseAnalyser): class PostgresqlAnalyser(BaseAnalyser):
migration_tests = [ migration_checks: list[Check] = [
{ Check(
"code": "CREATE_INDEX", code="CREATE_INDEX",
"fn": has_create_index, fn=has_create_index,
"msg": "CREATE INDEX locks table", message="CREATE INDEX locks table",
"mode": "transaction", mode=CheckMode.TRANSACTION,
"type": "warning", type=CheckType.WARNING,
}, ),
{ Check(
"code": "DROP_INDEX", code="DROP_INDEX",
"fn": lambda sql, **kw: re.search("DROP INDEX", sql) fn=lambda sql, **kw: re.search("DROP INDEX", sql)
and not re.search("INDEX CONCURRENTLY", sql), and not re.search("INDEX CONCURRENTLY", sql),
"msg": "DROP INDEX locks table", message="DROP INDEX locks table",
"mode": "one_liner", mode=CheckMode.ONE_LINER,
"type": "warning", type=CheckType.WARNING,
}, ),
{ Check(
"code": "REINDEX", code="REINDEX",
"fn": lambda sql, **kw: sql.startswith("REINDEX"), fn=lambda sql, **kw: sql.startswith("REINDEX"),
"msg": "REINDEX locks table", message="REINDEX locks table",
"mode": "one_liner", mode=CheckMode.ONE_LINER,
"type": "warning", type=CheckType.WARNING,
}, ),
] ]
+27 -21
View File
@@ -1,31 +1,35 @@
from __future__ import annotations
import re import re
from .base import BaseAnalyser from .base import BaseAnalyser, Check, CheckMode, CheckType
class SqliteAnalyser(BaseAnalyser): class SqliteAnalyser(BaseAnalyser):
migration_tests = [ migration_checks: list[Check] = [
{ Check(
"code": "RENAME_TABLE", code="RENAME_TABLE",
"fn": lambda sql, **kw: re.search("ALTER TABLE .* RENAME TO", sql) fn=lambda sql, **kw: re.search("ALTER TABLE .* RENAME TO", sql)
and "__old" not in sql and "__old" not in sql
and "new__" not in sql, and "new__" not in sql,
"type": "error", message="RENAMING tables",
}, mode=CheckMode.ONE_LINER,
{ type=CheckType.ERROR,
"code": "DROP_TABLE", ),
Check(
code="DROP_TABLE",
# TODO: improve to detect that the table names overlap # TODO: improve to detect that the table names overlap
"fn": lambda sql_statements, **kw: any( fn=lambda sql_statements, **kw: any(
sql.startswith("DROP TABLE") for sql in sql_statements sql.startswith("DROP TABLE") for sql in sql_statements
) )
and not any(sql.startswith("CREATE TABLE") for sql in sql_statements), and not any(sql.startswith("CREATE TABLE") for sql in sql_statements),
"msg": "DROPPING table", message="DROPPING table",
"mode": "transaction", mode=CheckMode.TRANSACTION,
"type": "error", type=CheckType.ERROR,
}, ),
{ Check(
"code": "NOT_NULL", code="NOT_NULL",
"fn": lambda sql_statements, **kw: any( fn=lambda sql_statements, **kw: any(
re.search("NOT NULL(?! PRIMARY)(?! DEFAULT)", sql) re.search("NOT NULL(?! PRIMARY)(?! DEFAULT)", sql)
for sql in sql_statements for sql in sql_statements
) )
@@ -34,13 +38,14 @@ class SqliteAnalyser(BaseAnalyser):
and ("__old" in sql or "new__" in sql) and ("__old" in sql or "new__" in sql)
for sql in sql_statements for sql in sql_statements
), ),
"mode": "transaction", message="NOT NULL constraint on columns",
"type": "error", mode=CheckMode.TRANSACTION,
}, type=CheckType.ERROR,
),
] ]
@staticmethod @staticmethod
def detect_table(sql): def detect_table(sql: list[str] | str) -> str | None:
if isinstance(sql, str): if isinstance(sql, str):
regex_result = re.search("TABLE [`\"'](.*?)[`\"']", sql, re.IGNORECASE) regex_result = re.search("TABLE [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result: if regex_result:
@@ -48,3 +53,4 @@ class SqliteAnalyser(BaseAnalyser):
regex_result = re.search("ON [`\"'](.*?)[`\"']", sql, re.IGNORECASE) regex_result = re.search("ON [`\"'](.*?)[`\"']", sql, re.IGNORECASE)
if regex_result: if regex_result:
return regex_result.group(1) return regex_result.group(1)
return None
@@ -1,21 +0,0 @@
from copy import deepcopy
def find_error_dict_with_code(tests, code):
return next((test_dict for test_dict in tests if test_dict["code"] == code), None)
def update_migration_tests(base_tests, specific_tests):
base_tests = deepcopy(base_tests)
for override_test in specific_tests:
migration_test_dict = find_error_dict_with_code(
base_tests, override_test["code"]
)
if migration_test_dict is None or not override_test["code"]:
migration_test_dict = {}
base_tests.append(migration_test_dict)
for key in override_test.keys():
migration_test_dict[key] = override_test[key]
return base_tests
+11 -5
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import os import os
from importlib import import_module from importlib import import_module
def split_path(path): def split_path(path: str) -> list[str]:
decomposed_path = [] decomposed_path: list[str] = []
while 1: while 1:
head, tail = os.path.split(path) head, tail = os.path.split(path)
if head == path: # sentinel for absolute paths if head == path: # sentinel for absolute paths
@@ -21,20 +23,21 @@ def split_path(path):
return decomposed_path return decomposed_path
def split_migration_path(migration_path): def split_migration_path(migration_path: str) -> tuple[str, str]:
from django.db.migrations.loader import MIGRATIONS_MODULE_NAME from django.db.migrations.loader import MIGRATIONS_MODULE_NAME
decomposed_path = split_path(migration_path) decomposed_path = split_path(migration_path)
for i, p in enumerate(decomposed_path): for i, p in enumerate(decomposed_path):
if p == MIGRATIONS_MODULE_NAME: if p == MIGRATIONS_MODULE_NAME:
return decomposed_path[i - 1], os.path.splitext(decomposed_path[i + 1])[0] return decomposed_path[i - 1], os.path.splitext(decomposed_path[i + 1])[0]
return "", ""
def clean_bytes_to_str(byte_input): def clean_bytes_to_str(byte_input: bytes) -> str:
return byte_input.decode("utf-8").strip() return byte_input.decode("utf-8").strip()
def get_migration_abspath(app_label, migration_name): def get_migration_abspath(app_label: str, migration_name: str) -> str:
from django.db.migrations.loader import MigrationLoader from django.db.migrations.loader import MigrationLoader
module_name, _ = MigrationLoader.migrations_module(app_label) module_name, _ = MigrationLoader.migrations_module(app_label)
@@ -42,6 +45,9 @@ def get_migration_abspath(app_label, migration_name):
migration_module = import_module(migration_path) migration_module = import_module(migration_path)
migration_file = migration_module.__file__ migration_file = migration_module.__file__
if not migration_file:
raise ValueError("Migration file not found")
if migration_file.endswith(".pyc"): if migration_file.endswith(".pyc"):
migration_file = migration_file[:-1] migration_file = migration_file[:-1]
return migration_file return migration_file
+2
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
from __future__ import annotations
import os import os
import sys import sys
+4 -1
View File
@@ -1,3 +1,6 @@
flake8==6.0.0 flake8==6.0.0
black==22.12.0 black==22.12.0
isort==5.11.3 isort==5.11.4
mypy==0.991
types-toml==0.10.8.1
ruff==0.0.194
+8
View File
@@ -1,6 +1,9 @@
[flake8] [flake8]
max-line-length = 88 max-line-length = 88
[ruff]
line-length = 88
[isort] [isort]
multi_line_output = 3 multi_line_output = 3
include_trailing_comma = True include_trailing_comma = True
@@ -8,3 +11,8 @@ force_grid_wrap = 0
use_parentheses = True use_parentheses = True
ensure_newline_before_comments = True ensure_newline_before_comments = True
line_length = 88 line_length = 88
add_imports =
from __future__ import annotations
[mypy]
ignore_missing_imports = True
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import ast import ast
import re import re
from os import path from os import path
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
CREATE_TABLE_WITH_NOT_NULL_COLUMN = "app_create_table_with_not_null_column" CREATE_TABLE_WITH_NOT_NULL_COLUMN = "app_create_table_with_not_null_column"
ADD_NOT_NULL_COLUMN = "app_add_not_null_column" ADD_NOT_NULL_COLUMN = "app_add_not_null_column"
DROP_COLUMN = "app_drop_column" DROP_COLUMN = "app_drop_column"
+42 -45
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import os import os
import unittest import unittest
import unittest.mock as mock import unittest.mock as mock
@@ -7,6 +9,7 @@ from django.db.migrations import Migration
from django_migration_linter import ( from django_migration_linter import (
IgnoreMigration, IgnoreMigration,
Issue,
MigrationLinter, MigrationLinter,
analyse_sql_statements, analyse_sql_statements,
get_migration_abspath, get_migration_abspath,
@@ -29,7 +32,7 @@ class CacheTestCase(unittest.TestCase):
], ],
) )
def test_cache_normal(self, *args): def test_cache_normal(self, *args):
linter = MigrationLinter(self.test_project_path) linter = MigrationLinter(self.test_project_path, database="mysql")
linter.old_cache.clear() linter.old_cache.clear()
linter.old_cache.save() linter.old_cache.save()
@@ -43,22 +46,20 @@ class CacheTestCase(unittest.TestCase):
cache = linter.new_cache cache = linter.new_cache
cache.load() cache.load()
self.assertEqual("OK", cache["4a3770a405738d457e2d23e17fb1f3aa"]["result"]) self.assertEqual("OK", cache["ab2eef5ceb020d0e6eaa42dda2a754c5"]["result"])
self.assertEqual("ERR", cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["result"]) self.assertEqual("ERR", cache["52000d9d2c43dfda982945000dc6ba54"]["result"])
self.assertListEqual( self.assertListEqual(
[ [
{ Issue(
"msg": "NOT NULL constraint on columns", code="NOT_NULL",
"code": "NOT_NULL", message="NOT NULL constraint on columns",
"table": None, ),
"column": None,
}
], ],
cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["errors"], cache["52000d9d2c43dfda982945000dc6ba54"]["errors"],
) )
# Start the Linter again -> should use cache now. # Start the Linter again -> should use cache now.
linter = MigrationLinter(self.test_project_path) linter = MigrationLinter(self.test_project_path, database="mysql")
with mock.patch( with mock.patch(
"django_migration_linter.migration_linter.analyse_sql_statements", "django_migration_linter.migration_linter.analyse_sql_statements",
@@ -95,18 +96,16 @@ class CacheTestCase(unittest.TestCase):
cache = linter.new_cache cache = linter.new_cache
cache.load() cache.load()
self.assertEqual("OK", cache["4a3770a405738d457e2d23e17fb1f3aa"]["result"]) self.assertEqual("OK", cache["ab2eef5ceb020d0e6eaa42dda2a754c5"]["result"])
self.assertEqual("ERR", cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["result"]) self.assertEqual("ERR", cache["52000d9d2c43dfda982945000dc6ba54"]["result"])
self.assertListEqual( self.assertListEqual(
[ [
{ Issue(
"msg": "NOT NULL constraint on columns", code="NOT_NULL",
"code": "NOT_NULL", message="NOT NULL constraint on columns",
"table": None, ),
"column": None,
}
], ],
cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["errors"], cache["52000d9d2c43dfda982945000dc6ba54"]["errors"],
) )
# Start the Linter again but with different database, should not be the same cache # Start the Linter again but with different database, should not be the same cache
@@ -122,18 +121,16 @@ class CacheTestCase(unittest.TestCase):
cache = linter.new_cache cache = linter.new_cache
cache.load() cache.load()
self.assertEqual("OK", cache["4a3770a405738d457e2d23e17fb1f3aa"]["result"]) self.assertEqual("OK", cache["ab2eef5ceb020d0e6eaa42dda2a754c5"]["result"])
self.assertEqual("ERR", cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["result"]) self.assertEqual("ERR", cache["52000d9d2c43dfda982945000dc6ba54"]["result"])
self.assertListEqual( self.assertListEqual(
[ [
{ Issue(
"msg": "NOT NULL constraint on columns", code="NOT_NULL",
"code": "NOT_NULL", message="NOT NULL constraint on columns",
"table": None, ),
"column": None,
}
], ],
cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["errors"], cache["52000d9d2c43dfda982945000dc6ba54"]["errors"],
) )
self.assertTrue(linter.has_errors) self.assertTrue(linter.has_errors)
@@ -169,7 +166,7 @@ class CacheTestCase(unittest.TestCase):
], ],
) )
def test_cache_modified(self, *args): def test_cache_modified(self, *args):
linter = MigrationLinter(self.test_project_path) linter = MigrationLinter(self.test_project_path, database="mysql")
linter.old_cache.clear() linter.old_cache.clear()
linter.old_cache.save() linter.old_cache.save()
@@ -183,7 +180,7 @@ class CacheTestCase(unittest.TestCase):
cache = linter.new_cache cache = linter.new_cache
cache.load() cache.load()
self.assertEqual("ERR", cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["result"]) self.assertEqual("ERR", cache["52000d9d2c43dfda982945000dc6ba54"]["result"])
# Get the content of the migration file and mock the open call to append # Get the content of the migration file and mock the open call to append
# some content to change the hash # some content to change the hash
@@ -209,9 +206,9 @@ class CacheTestCase(unittest.TestCase):
cache = linter.new_cache cache = linter.new_cache
cache.load() cache.load()
self.assertNotIn("19fd3ea688fc05e2cc2a6e67c0b7aa17", cache) self.assertNotIn("52000d9d2c43dfda982945000dc6ba54", cache)
self.assertEqual(1, len(cache)) self.assertEqual(1, len(cache))
self.assertEqual("ERR", cache["a25768641a0ad526fad199f97c303784"]["result"]) self.assertEqual("ERR", cache["edeb40ecc1f5550ebef876c8e8ca093d"]["result"])
@mock.patch( @mock.patch(
"django_migration_linter.MigrationLinter._gather_all_migrations", "django_migration_linter.MigrationLinter._gather_all_migrations",
@@ -221,7 +218,7 @@ class CacheTestCase(unittest.TestCase):
], ],
) )
def test_ignore_cached_migration(self, *args): def test_ignore_cached_migration(self, *args):
linter = MigrationLinter(self.test_project_path) linter = MigrationLinter(self.test_project_path, database="mysql")
linter.old_cache.clear() linter.old_cache.clear()
linter.old_cache.save() linter.old_cache.save()
@@ -235,23 +232,23 @@ class CacheTestCase(unittest.TestCase):
cache = linter.new_cache cache = linter.new_cache
cache.load() cache.load()
self.assertEqual("OK", cache["4a3770a405738d457e2d23e17fb1f3aa"]["result"]) self.assertEqual("OK", cache["ab2eef5ceb020d0e6eaa42dda2a754c5"]["result"])
self.assertEqual("ERR", cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["result"]) self.assertEqual("ERR", cache["52000d9d2c43dfda982945000dc6ba54"]["result"])
self.assertListEqual( self.assertListEqual(
[ [
{ Issue(
"msg": "NOT NULL constraint on columns", code="NOT_NULL",
"code": "NOT_NULL", message="NOT NULL constraint on columns",
"table": None, ),
"column": None,
}
], ],
cache["19fd3ea688fc05e2cc2a6e67c0b7aa17"]["errors"], cache["52000d9d2c43dfda982945000dc6ba54"]["errors"],
) )
# Start the Linter again -> should use cache now but ignore the erroneous # Start the Linter again -> should use cache now but ignore the erroneous
linter = MigrationLinter( linter = MigrationLinter(
self.test_project_path, ignore_name_contains="0002_add_new_not_null_field" self.test_project_path,
ignore_name_contains="0002_add_new_not_null_field",
database="mysql",
) )
with mock.patch( with mock.patch(
@@ -266,4 +263,4 @@ class CacheTestCase(unittest.TestCase):
cache = linter.new_cache cache = linter.new_cache
cache.load() cache.load()
self.assertEqual(1, len(cache)) self.assertEqual(1, len(cache))
self.assertEqual("OK", cache["4a3770a405738d457e2d23e17fb1f3aa"]["result"]) self.assertEqual("OK", cache["ab2eef5ceb020d0e6eaa42dda2a754c5"]["result"])
+7 -5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import os import os
import unittest import unittest
@@ -307,13 +309,13 @@ class RunSQLMigrationTestCase(unittest.TestCase):
runsql = migrations.RunSQL("sql;") runsql = migrations.RunSQL("sql;")
error, ignored, warning = self.linter.lint_runsql(runsql) error, ignored, warning = self.linter.lint_runsql(runsql)
self.assertEqual("RUNSQL_REVERSIBLE", warning[0]["code"]) self.assertEqual("RUNSQL_REVERSIBLE", warning[0].code)
def test_sql_linting_error(self): def test_sql_linting_error(self):
runsql = migrations.RunSQL("ALTER TABLE t DROP COLUMN t;") runsql = migrations.RunSQL("ALTER TABLE t DROP COLUMN t;")
error, ignored, warning = self.linter.lint_runsql(runsql) error, ignored, warning = self.linter.lint_runsql(runsql)
self.assertEqual("DROP_COLUMN", error[0]["code"]) self.assertEqual("DROP_COLUMN", error[0].code)
def test_sql_linting_error_array(self): def test_sql_linting_error_array(self):
runsql = migrations.RunSQL( runsql = migrations.RunSQL(
@@ -321,11 +323,11 @@ class RunSQLMigrationTestCase(unittest.TestCase):
) )
error, ignored, warning = self.linter.lint_runsql(runsql) error, ignored, warning = self.linter.lint_runsql(runsql)
self.assertEqual("DROP_COLUMN", error[0]["code"]) self.assertEqual("DROP_COLUMN", error[0].code)
self.assertEqual("RENAME_COLUMN", error[1]["code"]) self.assertEqual("RENAME_COLUMN", error[1].code)
def test_sql_linting_error_args(self): def test_sql_linting_error_args(self):
runsql = migrations.RunSQL([("ALTER TABLE %s DROP COLUMN %s;", ("t", "c"))]) runsql = migrations.RunSQL([("ALTER TABLE %s DROP COLUMN %s;", ("t", "c"))])
error, ignored, warning = self.linter.lint_runsql(runsql) error, ignored, warning = self.linter.lint_runsql(runsql)
self.assertEqual("DROP_COLUMN", error[0]["code"]) self.assertEqual("DROP_COLUMN", error[0].code)
@@ -1,3 +1,5 @@
from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
from django.core.management import call_command from django.core.management import call_command
@@ -1,3 +1,5 @@
from __future__ import annotations
import os import os
import shutil import shutil
import tempfile import tempfile
@@ -1,3 +1,5 @@
from __future__ import annotations
import os import os
import unittest import unittest
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2019-09-20 20:01 # Generated by Django 2.2 on 2019-09-20 20:01
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2019-09-20 20:02 # Generated by Django 2.2 on 2019-09-20 20:02
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.1.4 on 2019-03-19 20:59 # Generated by Django 2.1.4 on 2019-03-19 20:59
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.1.4 on 2019-03-19 21:05 # Generated by Django 2.1.4 on 2019-03-19 21:05
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 14:54 # Generated by Django 1.11.18 on 2019-04-14 14:54
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 14:55 # Generated by Django 1.11.18 on 2019-04-14 14:55
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
from django_add_default_value import AddDefaultValue from django_add_default_value import AddDefaultValue
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 14:56 # Generated by Django 1.11.18 on 2019-04-14 14:56
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 14:56 # Generated by Django 1.11.18 on 2019-04-14 14:56
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.1.4 on 2019-03-19 20:59 # Generated by Django 2.1.4 on 2019-03-19 20:59
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.1.4 on 2019-03-19 21:05 # Generated by Django 2.1.4 on 2019-03-19 21:05
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.1.4 on 2019-03-19 21:08 # Generated by Django 2.1.4 on 2019-03-19 21:08
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.1.4 on 2019-03-19 21:08 # Generated by Django 2.1.4 on 2019-03-19 21:08
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 14:59 # Generated by Django 1.11.18 on 2019-04-14 14:59
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-01-26 17:43 # Generated by Django 2.2 on 2020-01-26 17:43
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-01-26 17:43 # Generated by Django 2.2 on 2020-01-26 17:43
from __future__ import annotations
from django.db import migrations from django.db import migrations
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-01-26 17:47 # Generated by Django 2.2 on 2020-01-26 17:47
from __future__ import annotations
from django.db import migrations from django.db import migrations
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 14:59 # Generated by Django 1.11.18 on 2019-04-14 14:59
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 14:59 # Generated by Django 1.11.18 on 2019-04-14 14:59
from __future__ import annotations
from django.db import migrations from django.db import migrations
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 14:59 # Generated by Django 1.11.18 on 2019-04-14 14:59
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.1.10 on 2019-07-19 09:50 # Generated by Django 2.1.10 on 2019-07-19 09:50
from __future__ import annotations
from django.db import migrations from django.db import migrations
+1 -1
View File
@@ -1,4 +1,4 @@
from django.db import models # from django.db import models
# class A(models.Model): # class A(models.Model):
# field_a = models.IntegerField() # field_a = models.IntegerField()
@@ -1,5 +1,7 @@
# Generated by Django 2.1.4 on 2019-03-21 20:40 # Generated by Django 2.1.4 on 2019-03-21 20:40
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.1.4 on 2019-03-21 20:41 # Generated by Django 2.1.4 on 2019-03-21 20:41
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
import django_migration_linter as linter import django_migration_linter as linter
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-06-20 15:17 # Generated by Django 2.2 on 2020-06-20 15:17
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-06-20 15:17 # Generated by Django 2.2 on 2020-06-20 15:17
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-06-20 15:23 # Generated by Django 2.2 on 2020-06-20 15:23
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-06-20 15:23 # Generated by Django 2.2 on 2020-06-20 15:23
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
from django_add_default_value import AddDefaultValue from django_add_default_value import AddDefaultValue
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 15:02 # Generated by Django 1.11.18 on 2019-04-14 15:02
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 15:02 # Generated by Django 1.11.18 on 2019-04-14 15:02
from __future__ import annotations
from django.db import migrations from django.db import migrations
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 15:00 # Generated by Django 1.11.18 on 2019-04-14 15:00
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,6 +1,8 @@
# Generated by Django 1.11.18 on 2019-04-14 15:00 # Generated by Django 1.11.18 on 2019-04-14 15:00
from __future__ import annotations
from django.db import migrations from django.db import migrations
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2019-07-29 21:21 # Generated by Django 2.2 on 2019-07-29 21:21
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2019-07-29 21:22 # Generated by Django 2.2 on 2019-07-29 21:22
from __future__ import annotations
from django.db import migrations from django.db import migrations
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2019-07-29 21:22 # Generated by Django 2.2 on 2019-07-29 21:22
from __future__ import annotations
from django.db import migrations from django.db import migrations
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-06-26 17:49 # Generated by Django 2.2 on 2020-06-26 17:49
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
@@ -1,5 +1,7 @@
# Generated by Django 2.2 on 2020-06-26 15:57 # Generated by Django 2.2 on 2020-06-26 15:57
from __future__ import annotations
from django.db import migrations, models from django.db import migrations, models
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
+2 -2
View File
@@ -10,11 +10,11 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.1/ref/settings/ https://docs.djangoproject.com/en/2.1/ref/settings/
""" """
from __future__ import annotations
import os import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
from collections import OrderedDict
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+2
View File
@@ -13,6 +13,8 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from __future__ import annotations
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
+2
View File
@@ -7,6 +7,8 @@ For more information on this file, see
https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/
""" """
from __future__ import annotations
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
+4 -2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import unittest import unittest
from django_migration_linter.sql_analyser import ( from django_migration_linter.sql_analyser import (
@@ -28,9 +30,9 @@ class SqlAnalyserTestCase(unittest.TestCase):
self.assertNotEqual(0, len(errors), "Found no errors in sql") self.assertNotEqual(0, len(errors), "Found no errors in sql")
if code: if code:
self.assertTrue( self.assertTrue(
any(err["code"] == code for err in errors), any(err.code == code for err in errors),
"Didn't find error code {} in returned errors ({})".format( "Didn't find error code {} in returned errors ({})".format(
code, [err["code"] for err in errors] code, [err.code for err in errors]
), ),
) )
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import unittest import unittest
from django_migration_linter.utils import split_migration_path, split_path from django_migration_linter.utils import split_migration_path, split_path
+2
View File
@@ -32,6 +32,8 @@ commands =
flake8 django_migration_linter/ flake8 django_migration_linter/
black --check django_migration_linter/ tests/ manage.py setup.py black --check django_migration_linter/ tests/ manage.py setup.py
isort --check-only . isort --check-only .
mypy django_migration_linter/
ruff django_migration_linter/
[testenv:coverage] [testenv:coverage]
commands = commands =