File: migrations.py

package info (click to toggle)
pylint-django 2.6.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 248 kB
  • sloc: python: 1,429; sh: 14; makefile: 7
file content (178 lines) | stat: -rw-r--r-- 6,319 bytes parent folder | download
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,
    )