Автоматизация скучных вещей в Django с помощью фреймворка Check

Spread the love

В этой статье я описал примеры из моего личного опыта, как мы используем библиотеки inspect, ast и фреймворк входящий в Django check для улучшения нашего процесса разработки

У каждой команды свой стиль разработки. Некоторые команды осуществляют локализацию и создают перевод всего тестового контента. Некоторые команды более чувствительны к проблемам с базами данных и требуют более тщательной обработки индексов и ограничений. Существующие инструменты не всегда могут решить эти конкретные проблемы из коробки, поэтому мы придумали способ реализации нашего собственного стиля разработки с использованием фреймворка check (входящего в Django), модулей inspect и ast из стандартной библиотеки Python.

Что такое check в Django?

Check (Проверка) в Django являются частью инфраструктуры системных проверко Django (Django System Check framework). Из официальной документации:

Система проверки — это набор статических проверок для валидации проектов Django. Он позволяет обнаруживать общие проблемы и предоставляет советы по их устранению. Фреймворк является расширяемым, поэтому вы можете легко добавлять свои собственные проверки.

Одна проверка, с которой вы, возможно, сталкивались, это проверка системы административной панели:

SystemCheckError: System check identified some issues:

ERRORS:
<class 'app.admin.BarAdmin>
(admin.E108) The value of 'list_display[3]' refers to 'foo',
which is not a callable, an attribute of 'Bar', or an attribute
or method on 'app.Bar'.

Разработчики панели администратора добавили системную проверку, чтобы предупредить разработчиков о полях в административной модели, которых нет в реальной модели. В нашем примере говориться что поле ‘foo‘ не существует в модели Bar.

Проверки выполняются всякий раз при запуске некоторых команд управления, таких как makemigrations и migrate. Также возможно явно запустить проверку, используя manage.py:

$ ./manage.py check

Хорошая идея, чтобы включить запуск проверок в ваш процесс CI. Если вы хотите что бы не прохождение проверок регистрировалось в CI как предупреждение, вы можете сделать это, установив флаг:

$ ./manage.py check --fail-level=WARNING

Простой пример того, как Django использует проверки, можно найти в исходном коде модели Field проверки.

Наша первая проверка

Большинство наших приложений не предназначены для носителей английского языка, поэтому мы широко используем переводы. Этому мы уделяем большое внимание при проверке кода, чтобы убедиться, что все переведено правильно.

Одна из основных проблем, возникающих при проверке кода, заключается в том, что разработчики часто забывают установить verbose_name в полях модели.

Проверка того, что у поля есть verbose_name, является довольно простой задачей, и мы хотели автоматизировать процесс, чтобы быть уверенными, что оно всегда установлено.

Для начала мы определим простую модель профиля клиента:

class CustomerProfile(models.Model):

    id = models.PositiveSmallIntegerField(
        primary_key=True,
        verbose_name=_('id'),
    )

    name = models.CharField(
        max_length=100,
    )

    created_by = models.ForeignKey(
        User,
        on_delete=models.PROTECT,
    )

В нашем примере поле «name» не имеет verbose_name. Давайте посмотрим, сможем ли мы определить это, используя только атрибут _meta модели:

>>> name_field = CustomerProfile._meta.get_field('name')
>>> name_field.verbose_name
name

Похоже, Django сделало что-то в бекграунде, чтобы установить verbose_name. Если посмотреть в содержимое исходников класса Field, то там есть функция set_attributes_from_name, которая заполняет verbose_name путем преобразования имени поля — отсюда и получилось verbose_name равное «name».

Поскольку Django устанавливает verbose_name самостоятельно, строка «name» не будет воспринята makemessages и не будет автоматически добавлена в po-файл. Это, вероятно, приведет к тому, что строка «name» останется незамеченной. Это не то что нам нужно!

Кроме того, поскольку Django заполняет поле автоматически, мы не можем использовать _meta, чтобы проверить, было ли изначально задано verbose_name. Для этого нам нужно проверить фактический исходный код.

Модуль inspect

В Python есть модуль inspect, который мы можем использовать для проверки кода:

Модуль inspect предоставляет несколько полезных функций, которые помогают получить информацию о объектах, таких как модули, классы, методы, функции, обратные вызовы, объекты фреймов и объекты кода. Например, он может помочь вам изучить содержимое класса, получить исходный код метода, извлечь и отформатировать список аргументов для функции или получить всю информацию, необходимую для отображения подробного traceback.

Давайте посмотрим, что мы можем получить из inspect:

>>> import inspect
>>> inspect.getsource(CustomerProfile)

"class CustomerProfile(models.Model):\n id = models.PositiveSmallIntegerField(\n
primary_key=True,\n verbose_name=_('Name'),\n )\n name = models.CharField(\n
max_length=100,\n )\n created_by = models.ForeignKey(\n User,\n on_delete=models.PROTECT,\n
)\n\n def __str__(self):\n return self.name\n"

Это довольно захватывающе. Мы дали inspect наш класс и получили исходный код для этого класса в виде текста.

Получив исходный код, мы могли бы использовать какой-то необычный RegExp для разбора кода, но, опять же, в Python все уже есть для нашей задачи.

Разбор кода

Разбор кода в Python выполняется модулем ast:

Модуль ast помогает приложениям Python обрабатывать деревья грамматики абстрактного синтаксиса Python.

Супер! С деревом работать намного проще, чем с текстом.

Используем ast для анализа исходного кода нашей модели:

>>> import inspect
>>> import ast
>>> model_source = inspect.getsource(CustomerProfile)
>>> model_node = ast.parse(model_source)
>>> ast.dump(model_node, False)

Module([
    ClassDef('CustomerProfile',
    [Attribute(Name('models', Load()), 'Model', Load())],
    [],
    [

        Assign(
            [Name('id', Store())],
            Call(
                Attribute(Name('models', Load()), 'PositiveSmallIntegerField', Load()),
                [],
                [
                keyword('primary_key', NameConstant(True)),
                keyword('verbose_name', Call(Name('_', Load()), [Str('Name')], []))
                ]
            )
        ),

        Assign(
            [Name('name', Store())],
            Call(
                Attribute(Name('models', Load()), 'CharField', Load()),
                [],
                [keyword('max_length', Num(100))]
            )
        ),

        Assign(
            [Name('created_by', Store())],
            Call(
                Attribute(Name('models', Load()), 'ForeignKey', Load()),
                [Name('User', Load())],
                [keyword('on_delete', Attribute(Name('models', Load()), 'PROTECT', Load()))]
            )
        ),

        FunctionDef(
            '__str__',
            arguments([arg('self', None)],None,[],[],None,[]),
            [Return(Attribute(Name('self', Load()), 'name', Load()))], [], None
        )
    ],
    []
    )
])

Если мы внимательно посмотрим на этот дамп, мы можем определить, что все наши поля модели являются узлами Assign.

Давайте рассмотрим поле «name»:

Assign(
    [Name('name', Store())],
    Call(
        Attribute(Name('models', Load()), 'CharField', Load()),
        [],
        [keyword('max_length', Num(100))]
    )
)

Поле модели — это назначение узла Call (CharField) узлу Namename»). Узел Call имеет список аргументов. В нашем случае у нас есть только один аргумент «max_length» с числовым значением 100.

Наше поле id выглядит так:

Assign(
    [Name('id', Store())],
    Call(
        Attribute(Name('models', Load()), 'PositiveSmallIntegerField', Load()), [], [
            keyword('primary_key', NameConstant(True)),
            keyword('verbose_name', Call(
               Name('_', Load()), [Str('Name')], []
            )
        )
    ])
)

Поле id также является узлом Assign с узлом Name и узлом Call. Поле id имеет два ключевых слова — primary_key и verbose_name, которое мы и ищем.

Оценка поля модели

Для оценки полей нам сначала нужно их идентифицировать. Мы уже видели, что поля модели являются узлами Assign, но мы не можем полагаться на то, что они являются единственными узлами Assign в классе.

Единственное, на что мы можем положиться, это то, что на верхнем уровне класса имена атрибутов уникальны. Это означает, что если мы знаем, что есть поле с именем «name», мы можем предположить, что атрибут «name» класса является полем.

Давайте объединим усилия с атрибутом _meta, чтобы найти узлы полей модели:

from django.db.models import FieldDoesNotExist


for node in model_node.body[0].body:
    if not isinstance(node, ast.Assign):
        continue

    if len(node.targets) != 1:
        continue

    if not isinstance(node.targets[0], ast.Name):
        continue

    field_name = node.targets[0].id
    try:
        field = model._meta.get_field(field_name)
    except FieldDoesNotExist:
        continue

   # node is field!

Здесь делается следующее:

  1. Поля модели определены на верхнем уровне класса — нам нужно только проверять атрибуты, определенные на верхнем уровне (нет необходимости рекурсивно «посещать» все узлы).
  2. У полей модели должно быть target Name — имя поля.
  3. Наконец, поле, которое мы ищем, должно быть зарегистрировано в модели Django как поле модели.

Теперь у нас есть узел поля, и мы можем проверить, определен ли атрибут verbose_name.

Давайте итерируем keywords и поищем verbose_name:

for kw in node.value.keywords:
    if kw.arg == 'verbose_name':
       verbose_name = kw
        break
else:
    verbose_name = None

На данный момент, если verbose_name имеет значение None, мы знаем, что атрибут не был установлен, и мы готовы создать наше первое предупреждение!

Проверки Django

Для создание проверки нам нужно зарегистрировать нашу новую функцию проверки с помощью фреймворка check:

from django.core import check checks

@checks.register(checks.Tags.models)
def run_custom_checks(app_configs, **kwargs):
    # implement check logic

Внутри функции мы реализуем логику проверки и вернем список проверок.

Мы хотим предупредить разработчика, что в поле отсутствует атрибут verbose_name, поэтому, как только мы найдем поле, которое не имеет verbose_name, мы создаем CheckMessage типа Warning:

from django.core.checks import Warning

@checks.register(checks.Tags.models)
def run_custom_checks(app_configs, **kwargs):

    # inspect and parse models...

    return [(
        Warning(
            'Field has no verbose name',
            hint='Set verbose name on field {}.'.format(field.name),
            obj=field,
            id='H001',
        )
    )]

Я назначил код H00X своим предупреждениям (угадайте почему …). Для каждого предупреждения мы также можем добавить подсказку, чтобы проинформировать разработчика о том, как решить проблему, вызванную предупреждением.

Собираем все вместе

Напомним, что мы сделали до сих пор:

  1. Получили исходный код для модели, используя inspect.
  2. Разобрали исходный код модели с помощью ast и определили узлы поля.
  3. Исследовали узел поля и проверили, определено ли verbose_name.
  4. Зарегистрировали функцию в рамках проверки и вернули предупреждение.

Каркас функции, которая проверяет одну модель:

# common/checks.py

def check_model(model):
   """Check a single model.

   Yields (django.checks.CheckMessage)
   """
   model_source = inspect.getsource(model)
   model_node = ast.parse(model_source)

   for node in model_node.body[0].body:

       # Check if node is a model field.

       # Check if field has verbose name defined

       yield Warning(
            'Field has no verbose name',
            hint='Set verbose name on field {}.'.format(field.name),
            obj=field,
            id='H001',
        )

Следующим шагом является реализация единой функции для итерации по всем моделям, запуска наших проверок и регистрации ее в инфраструктуре проверки Django:

# common/checks.py

@checks.register(checks.Tags.models)
def check_models(app_configs, **kwargs):
    errors = []
    for app in django.apps.apps.get_app_configs():

        # Skip third party apps.
        if app.path.find('site-packages') > -1:
            continue

        for model in app.get_models():
            for check_message in check_model(model):
                errors.append(check_message)

    return errors

Мы используем небольшой трюк, чтобы пропустить модели из сторонних приложений. Мы предполагаем, что при установке сторонних приложений с помощью pip install они устанавливаются в каталог под названием «site-packages».

Единственное, что осталось сделать, это импортировать этот файл куда-нибудь в код и все готово.

app/__init__.py

from common.checks import

Давайте посмотрим нашу новую проверку в действии:

$ ./manage.py check

SystemCheckError: System check identified some issues:

WARNINGS:
app.CustomerProfile.name: (H001) Field has no verbose name
HINT: Set verbose name on the field "name".

System check identified 1 issues (0 silenced).

Именно то, что мы хотели!


Пользовательские проверки в реальном мире

Чтобы понять, что вы можете делать с проверками Django, я приведу список проверок, которые мы используем в нашей базе кода:

H001: Поле не имеет verbose name.

Это пример, который мы только что видели.

H002: Verbose name должны использовать gettext.

Убедитесь, что verbose_name всегда имеет вид verbose_name = _ (‘text’). Если значение не использует gettext, оно не будет переведено.

H003: Слова в verbose name должны быть только в верхнем регистре или в нижнем .

Мы решили использовать только нижний регистр букв в verbose_name. Используя нижний регистр, мы смогли повторно использовать фразы в большинстве переводов. Единственным исключением из этого правила являются такие сокращения, как API и ETL. Общее правило, которым мы в конечном итоге придерживались, заключается в том, чтобы все слова были либо в нижнем, либо в верхнем регистре. Например, «etl run» допустим, «ETL run» также допустим, «Etl Run» недопустимо.

H004: Атрибут Help должен так же использовать gettext.

Текст справки отображается пользователю в административных формах и подробных вьюхах, поэтому он должен так же использовать gettext и переводиться.

H005: В модели должен быть определен класс Meta.

Перевод названия модели определен в мета-классе модели, поэтому каждая модель должна иметь класс Meta.

H006: В модели должен быть определен атрибут verbose name.

verbose names моделей определены в классе Meta и отображаются пользователю в админке, поэтому их следует переводить.

H007: В модели должен быть определен атрибут verbose name plural.

Множественные названия моделей используются в админке и отображаются пользователю, поэтому они должны быть переведены.

H008: Необходимо явно указать db_index в поле ForeignKey.

Это должно быть самой полезной проверкой, которую мы определили. Эта проверка заставляет разработчика явно устанавливать db_index для каждого поля ForeignKey. В прошлом я писал о том, как индекс базы данных создается неявно для каждого поля внешнего ключа ( how a database index is created implicitly for every foreign key field). Убедившись в том, что разработчик знает об этом, нужно заставить его решить, нужен ли индекс или нет, в итоге у вас останутся только те индексы, которые вам действительно нужны!

Оригинальная статья: Haki Benita Automating the Boring Stuff in Django Using the Check Framework

Была ли вам полезна эта статья?
[1 / 5]

Spread the love
Подписаться
Уведомление о
guest
1 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Павел
Павел
5 лет назад

Познавательно. Спасибо что поделились опытом. Возможно тоже буду внедрять такие проверки в своих проектах.