1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
|
# Copyright (c) 2018, 2020 Alexander Todorov <atodorov@MrSenko.com>
# Copyright (c) 2020 Bryan Mutai <mutaiwork@gmail.com>
# Licensed under the GPL 2.0: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
# For details: https://github.com/pylint-dev/pylint-django/blob/master/LICENSE
"""
Various suggestions around migrations. Disabled by default! Enable with
pylint --load-plugins=pylint_django.checkers.migrations
"""
import astroid
from pylint import checkers
from pylint_plugin_utils import suppress_message
from pylint_django import compat
from pylint_django.__pkginfo__ import BASE_ID
from pylint_django.compat import check_messages
from pylint_django.utils import is_migrations_module
def _is_addfield_with_default(call):
if not isinstance(call.func, astroid.Attribute):
return False
if not call.func.attrname == "AddField":
return False
for keyword in call.keywords:
# looking for AddField(..., field=XXX(..., default=Y, ...), ...)
if keyword.arg == "field" and isinstance(keyword.value, astroid.Call):
# loop over XXX's keywords
# NOTE: not checking if XXX is an actual field type because there could
# be many types we're not aware of. Also the migration will probably break
# if XXX doesn't instantiate a field object!
for field_keyword in keyword.value.keywords:
if field_keyword.arg == "default":
return True
return False
class NewDbFieldWithDefaultChecker(checkers.BaseChecker):
"""
Looks for migrations which add new model fields and these fields have a
default value. According to Django docs this may have performance penalties
especially on large tables:
https://docs.djangoproject.com/en/2.0/topics/migrations/#postgresql
The preferred way is to add a new DB column with null=True because it will
be created instantly and then possibly populate the table with the
desired default values.
"""
# configuration section name
name = "new-db-field-with-default"
msgs = {
f"W{BASE_ID}98": (
"%s AddField with default value",
"new-db-field-with-default",
"Used when Pylint detects migrations adding new fields with a default value.",
)
}
_migration_modules = []
_possible_offences = {}
def visit_module(self, node):
if is_migrations_module(node):
self._migration_modules.append(node)
def visit_call(self, node):
try:
module = node.frame().parent
except: # noqa: E722, pylint: disable=bare-except
return
if not is_migrations_module(module):
return
if _is_addfield_with_default(node):
if module not in self._possible_offences:
self._possible_offences[module] = []
if node not in self._possible_offences[module]:
self._possible_offences[module].append(node)
@check_messages("new-db-field-with-default")
def close(self):
def _path(node):
return node.path
# sort all migrations by name in reverse order b/c
# we need only the latest ones
self._migration_modules.sort(key=_path, reverse=True)
# filter out the last migration modules under each distinct
# migrations directory, iow leave only the latest migrations
# for each application
last_name_space = ""
latest_migrations = []
for module in self._migration_modules:
name_space = module.path[0].split("migrations")[0]
if name_space != last_name_space:
last_name_space = name_space
latest_migrations.append(module)
for module, nodes in self._possible_offences.items():
if module in latest_migrations:
for node in nodes:
self.add_message("new-db-field-with-default", args=module.name, node=node)
class MissingBackwardsMigrationChecker(checkers.BaseChecker):
name = "missing-backwards-migration-callable"
msgs = {
f"W{BASE_ID}97": (
"Always include backwards migration callable",
"missing-backwards-migration-callable",
"Always include a backwards/reverse callable counterpart so that the migration is not irreversible.",
)
}
@check_messages("missing-backwards-migration-callable")
def visit_call(self, node):
try:
module = node.frame().parent
except: # noqa: E722, pylint: disable=bare-except
return
if not is_migrations_module(module):
return
if node.func.as_string().endswith("RunPython") and len(node.args) < 2:
if node.keywords:
for keyword in node.keywords:
if keyword.arg == "reverse_code":
return
self.add_message("missing-backwards-migration-callable", node=node)
else:
self.add_message("missing-backwards-migration-callable", node=node)
def is_in_migrations(node):
"""
RunPython() migrations receive forward/backwards functions with signature:
def func(apps, schema_editor):
which could be unused. This augmentation will suppress all 'unused-argument'
messages coming from functions in migration modules.
"""
return is_migrations_module(node.parent)
def load_configuration(linter): # TODO this is redundant and can be removed
# don't blacklist migrations for this checker
new_black_list = list(linter.config.black_list)
if "migrations" in new_black_list:
new_black_list.remove("migrations")
linter.config.black_list = new_black_list
def register(linter):
"""Required method to auto register this checker."""
linter.register_checker(NewDbFieldWithDefaultChecker(linter))
linter.register_checker(MissingBackwardsMigrationChecker(linter))
if not compat.LOAD_CONFIGURATION_SUPPORTED:
load_configuration(linter)
# apply augmentations for migration checkers
# Unused arguments for migrations
suppress_message(
linter,
checkers.variables.VariablesChecker.leave_functiondef,
"unused-argument",
is_in_migrations,
)
|