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

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

Итак, приступим.

Для начала - начало

Возьмем существующий проект Django. Допустим, он размещен в "/home/alex/work/django/myproject", а файл settings.py соответственно в подкаталоге "myproject".

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

Почему автономный? — Ну да, можно создать стандартную management команду и сделать все внутри. Да, можно.

Но я хочу сделать как бы отдельную утилиту. Которой можно исследовать любой проект, не вписывая ее в структуру application. Зачем засорять предметную логику.

Посему, создадим в корне проекта файл с именем migrations_map.py и впишем ему в начало следующие строки:

DIRNAME = "/home/alex/work/django/myproject"
import os, sys
os.chdir(DIRNAME)
sys.path.insert(0,DIRNAME)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
import django; django.setup()

Тем самым мы инициализируем проект Django и активируем все модели. Мало того, такой файл можно запускать с любого места — он сам устанавливает нужный каталог для работы. И в общем то можно передавать а параметре путь к проекту — тогда это будет еще и универсальная утилита. Но это — потом. Или сами.

Итак. Теперь задача — прочесть все миграции и каким‑нибудь образом зафиксировать зависимости.

Считываем зависимости миграций.

Разбираясь с миграциями, я обнаружил интересное свойство — Django читает ВСЕ файлы в соответствующих каталогах проекта, даже не связанные зависимостями друг с другом и пытается их выполнять, как минимум — до определения класса.
Отсюда 2 вывода:

  1. Мы поступим так же — будем читать все файлы миграций.

  2. Не стоит оставлять в каталоге миграций мусор.

Идея чтения такова:

  • из регистра приложений проекта берем все подключенные приложения (application) и определяем их каталоги,

  • получаем список всех файлов .py в каталоге приложения

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

  • потом уже из массива зависимостей соберем диаграмму.

Тогда так, нам нужно импортировать регистр приложений:

from django.apps import apps

Ну и понадобится средство для импорта модулей:

from importlib import import_module

К стати, открою секрет — тут я пишу код на Pyhton 2.7. Ну во первых речь шла о старых проектах. Во вторых — есть некоторые сложности 2.7 версии. Но в принципе этот код запускается и на Python 3.

Сначала определим функцию, которая для заданной application будет возвращать нам список имен файлов миграций. Нам имена понадобятся не только что бы импортировать миграции, но и что бы сформировать граф зависимостей. Так как зависимости миграций указываются ровно по именам файлов.

def get_migrations_list(apps_path):
    """
        apps_path:str -- путь к каталогу приложения
    """
    res = []

    # Определяем путь к каталогу и читаем список файлов
    if apps_path.startswith("/"):
        mig_dir = u"{}/migrations".format(apps_path)
    else:
        mig_dir = u"{}/{}/migrations".format(DIRNAME, apps_path)
    try:
        files = list(os.listdir(mig_dir))
    except:
        return None

    for itm in files:
        fname, _, ext = itm.rpartition(".")
        # Если не Python файлы - нам не нужно
        if ext != "py":
            continue
        # Пропустим так же файл "__init__"
        if fname=="__init__":
            continue
        res.append(fname)
        
    return res

Тут понятно. Передаем строку — каталог приложения. (Где его взять — далее) С помощью метода os.listdir получаем список строк — имен файлов в каталоге. Тут к стати нам понадобится константа DIRNAME, которую мы определили выше. В старых Django (1.9) путь в классе конфигурации application указывался относительно каталога проекта. В Python 3 из‑за различий в управлении пакетами пут выдается в полном виде от корня файловой системы.

Почему это важно: Мы хотим отфильтровать приложения только наши и не отображать миграции используемых «внешних» библиотек. Самое удобное — различить их по пути. Для старой версии — по тому, что он НЕ начинается с «/», а для новой — по тому, что начало совпадает с каталогом проекта. Для более сложных случаев — можно уже подправить как будет нужно.

Фильтр по этому признаку сделаем далее, а тут нужно правильно составить имя каталога, содержащего файлы миграций.

Если делать нашу утилиту не как отдельный скрипт, а как management команду, Django — нужно будет позаботится определить каталог проекта и передать его сюда.

Теперь нужно отобрать только файлы Питона. Можно было бы воспользоваться библиотекой для «разделки на части» имени файла — но тут поступим «по рабоче‑крестьянски», ну то есть ручками. Благо все просто. По точке («.») разобьем строку и вычленим расширение.

Далее фильтруем по расширению. И еще нужно отфильтровать файлы "__init__", что особо важно для Python 2.7.

Теперь получим список приложений в проекте и соберем зависимости.

def get_migrations_map():
    res = []
    # Получим список application и пройдем по нему в цикле
    for itm in apps.get_app_configs():
        # Исключим приложения не из проекта
        if itm.path.startswith("/"):
            if not itm.path.startswith(DIRNAME):
                continue

        # Получаем список имен файлов миграций
        migs = get_migrations_list(itm.path)
        if not migs:
            continue
        for mn in migs:
            # Импортируем модуль миграции
            m1 = import_module(".".join((itm.name, "migrations", mn,)))

            # Находим класс миграции
            m_class = getattr(m1, "Migration", None)
            if not m_class:
                continue

            # Находим в классе список зависимостей
            m_deps = getattr(m_class, "dependencies", None)
            if not m_deps:
                continue

            # Формируем имя текущей миграции
            cur_name = (itm.label, mn,)
            for dep in m_deps:
                # Если не кортеж "обычной" зависимости - пропускаем
                if not isinstance(dep, (list, tuple)):
                    continue
                if len(dep)!=2:
                    continue

                # Формируем кортеж зависимости и вписываем в "карту"
                dep_name = (dep[0], dep[1])
                res.append((dep_name, cur_name))
    return res

Во первых строках с помощью регистра apps получим список конфигураций подключенных приложений: apps.get_app_configs().

Интересная деталь — для приложения, которые НЕ являются частью нашего проекта в свойстве path путь начинается от корня файловой системы. Для Unix — это «/». Миграции таких приложений меня не интересуют — их игнорю. Но можно и не делать такого — если хочется увидеть полную картину. Учитывая вышесказанное про пути в новых версиях — еще проверяем, не начинается ли полный путь с каталога проекта — таких оставляем.

Теперь воспользовавшись нашей функцией получаем список файлов миграций для приложения: get_migrations_list(itm.path). Вот и путь нашелся.

Теперь для каждого файла в списке сделаем его импорт. Для этого возьмем функцию import_module. Ей нужно передать строку с названием импортируемого модуля. Но не в формате файла, а в формате модуля. То есть через «.».

Как известно, миграции лежать в подкаталоге "migration", но где взять путь самого приложения. А он как раз есть в свойстве name конфигурации приложения. Функция нам вернет объект — импортированный модуль. Атрибуты этого объекта — переменные, объявленные в теле модуля.

Попробуем получить оттуда класс миграции. Можно посмотреть файл и мы увидим, что там определяется класс с названием "Migration". Вот попробуем его и получить с помощью getattr. А вдруг это не файл миграций? Тогда пропускаем и далее.

Аналогично, получаем атрибут "dependencies" класса миграций, в котором лежит список зависимостей. Если его вдруг нет — идем на следующий файл.

Список зависимостей

Теперь разберемся с зависимостями, и с тем, чего же мы хотим получить.

Понятно, что по сути мы хотим построить граф по зависимостям. То есть нам нужно получить список узлов этого графа и связи между ними. Сделаем по простому. В список будем добавлять в виде кортежа пару взаимосвязанных миграций: текущую и одну из ее dependencies. А миграцию в этом списке будем представлять кортежем (<application name>,<migration name>). Потом, когда будет строить уже диаграмму графа, мы воспользуемся именем приложения не только что бы сформировать полное название узла.

Итак, вернемся к нашей функции и сначала сформируем узел — текущую миграцию: cur_name = (itm.label, mn,). Тут нам очень к стати имя файла. А где взять имя приложения? В атрибуте label дескриптора.

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

Но тут есть тонкость. Обычно зависимость в файле миграций записана как кортеж (<application name>,<migration name>), но для начальных миграций используется другой тип. Пока просто проигнорим все другие типы.

Для этого проверим, что зависимость у нас — это кортеж с двумя элементами.

Добавили в список. Список вернули из функции. Ура! У нас будут данные графа зависимостей.

В принципе, для особо увлеченных, этот граф можно попытаться проанализировать автоматически. Но это нам пока не нужно. Нам бы глазками посмотреть.

Строим картинку

А как? — спросите Вы. Что, возьмем теперь какой‑нибудь tcl|tk или что еще и будем рисовать? Нее. Лениво. Мы воспользуемся инструментом создания диаграмм из текстового описания. Можно взять, например graphviz. Есть для него даже библиотека для Python, graphviz‑python. И возможно так было бы даже эффективнее. Так как не нужно будет формировать текст, а можно сразу из нашего графа сделать синтаксическое дерево диаграммы и отрисовать его. Но! Опять же лениво, и хочу показать более универсальный инструмент, не требующий установки локально библиотек визуализации.

Знакомьтесь: kroki.io. Этот сайт позволяет «скормить» ему текст диаграммы на впечатляющем количестве возможных языков и получить готовую картинку — svg или jpg.

Но нам нужно как‑то из программы сделать запрос «в Интернет» и считать результат. Для этого возьмем очень известную библиотеку requests. Соответственно, установим ее (если вы это не делали раньше), и подключим в проект:

import requests

Для формирования графа воспользуемся языком для graphviz (.DOT). Он достаточно простой и хорошо строит именно графы, в том числе очень большие.

digraph G {
    a -> b
    b -> c
    b -> d
}

Вот так выглядит определение простенького графа. "a", "b" и "c" — это узлы и заодно — имена (label) этих узлов.

Стрелками "->" задается связь между узлами. Далее процессор диаграммы разбирает это все и строит картинку:

Сделаем функцию, которая будет собирать текст диаграммы:

def node_name(node):
    return "{}__{}".format(*node)

def create_graph(mig_map, app_color_map):
    res = ""
    res += "digraph G {\n"
    for itm in mig_map:
        res += "{} -> {}\n".format(node_name(itm[0]), node_name(itm[1]))
    res += "}\n"
    return res

Первая функция — для формирования названия узла. Будем к названию приложения добавлять название миграции. По скольку эти названия соответствуют синтаксису обозначения узлов графа, то нам будет этого достаточно. В противном случае пришлось бы определять отдельно «label» (то есть надпись) для каждого узла.

Технически все просто: Делаем заголовок диаграммы, для каждой строчки в «графе миграций», полученном ранее формируем строку в тексте диаграммы. Добавляем футер файла.

Можно все это запустить и получим текст диаграммы по нашим миграциям. Но как посмотреть?

Получаем картинку

Как сказал выше, отправим полученный текст на сервер «kroki.io» и попросим его вернуть нам SVG картинку нашей диаграммы. Можно и jpg, но svg лучше масштабируется.

def store_diag_svg(diag_txt, out_file_name):
    with requests.post("https://kroki.io/graphviz/svg", data=diag_txt, 
        headers={"Content-Type": "text/plain"}, stream=True) as r:
        with open(out_file_name, "wb") as ff:
            for itm in r.iter_content(chunk_size=None):
                ff.write(itm)

В функцию будем передавать текст диаграммы, сформированный предыдущей функцией, и имя файла для сохранения результата.

Для запроса на сервер воспользуемся методом http POST. Дело в том, что для крупных проектов граф миграций, да еще с учетом текста диаграммы может быть большой. И GET запрос просто не пройдет на обычный сервер kroki.io. А POST — запросто.

Используем функцию requests.post. Первым параметром передаем в нее url. В url, понятно, сначала идет адрес сервера, затем /graphvis/, говорящий о том, что мы будем обрабатывать формат grphviz. Можете поэкспериментировать с другими форматами — там их много и есть такие же простые. Но это уже сами.

Далее элемент url /svg — говорит серверу о том, что мы хотим получить от него картинку в svg формате. Тут можно передать не /svg а /jpg, что бы получить картинку в растровом формате jpg. Я предпочитаю svg.

Следующий параметр в функцию — передаем текст нашей диаграммы в именованном параметре data.

Теперь нам нужно сказать серверу, где брать и как понимать наши данные. Он принимает несколько вариантов, в том числе JSON с дополнительными параметрами построения. Но мы пока ограничимся простым текстом, передаваемым в теле запроса. Для этого нужно установить http заголовок "Content-Type" в значение "text/plain". Это делаем путем передачи в параметре headers словаря с ключем — названием заголовка, и его значением.

Последний параметр функции запроса: stream=True. Это технический параметр, говорящий, что мы не хотим получать сразу всю картинку, а будем выбирать ее с сервера потоково по кусочкам. Что бы сразу сохранять на диск. Для больших картинок это актуально.

Соответственно, так как мы решили использовать потоковую выгрузку файла, то в результате запроса будет открыто долгоживущее соединение. Которое нужно корректно закрыть после получения всех данных или в случае ошибки. Для этого вызов requests.post мы используем в формате «контекст менеджера», поместив в конструкцию with. В переменной r у нас будет результат запроса. И как только мы закончим с ним работать — Python сам закроет открытое соединение.

Теперь сохраним результат. Тем более, что мы как то странно, «потоково» его получать решили.

Для этого откроем еще один контекст менеджер через стандартную функцию open. Она нам откроет файл с указанным нами именем в режиме "wb" — то есть запись бинарная. Тут важно именно бинарная (байтовая) запись, несмотря на то, что формат svg в общем то текстовый. Во первых для чтения из потока мы будем так же использовать бинарное чтение, а во вторых не нужно будет заморачиваться с «концом строки». И третье — точно таким же способом мы сможем получить и формат jpg.

Внутри строкой организуем цикл по итератору. Библиотека requests в ответе на запрос предоставляет динамический итератор по возвращаемым данным. .iter_content. Когда мы в запросе указали stream=True при каждом обращении в цикле к этому итератору будет получатся с сервера очередная порция данных. И так пока не скачаем все.
chunk_size=None говорит, что бы система использовала наиболее удобный размер буфера для выкачивания. Полученный «кусок» данных записываем в файл.

Как только скачаем все, итератор завершится, автоматически закроется файл и соединение. И мы получим на диске нашу диаграмму.

Ура!

Запускаем

Не, еще не ура. Мы же только определили функции. Это все нужно запустить:

fname = "/home/alex/work/django/myproject/mig_map1.svg"
res = get_migrations_map()
grf = create_graph(res)
store_diag_svg(grf, fname)

В результате, если все правильно написали и интернет работает — в файле /home/alex/work/django/myproject/mig_map1.svg получим нашу картинку. Можно ее открыть любым способом и побаловаться.

Может возникнуть проблема — у меня почему то системный просмоторщик картинок иногда некорректно открывает этот svg. Но браузер — без проблем. Тем более, что в браузере можно выбрать масштаб. И браузер, при наведении мышкой на узел показывает крупно его название. Для мелкого масштаба — очень полезно.

Красота.

Но как‑то для больших проектов не очень выразительно. В смысле того, что при сложных миграциях (а если бы было все просто — стали бы мы заморачиваться) линия одного application плохо просматривается. Вот бы покрасить миграции разных приложений в разный цвет. А попробуем.

Выделяем цветом

Для этого создадим структуру color_map — словарь, ключами у которого будут названия наших application, а значениями — строки с названиями цветов. Тут используются стандартные для CSS названия или обозначения.

Определим, например:

app_color_map = {
    'powder': "yellow",
}

Теперь учтем эту «карту раскраски» при создании диаграммы. Во первых строках поймем, как это делается в языке graphviz:

digraph G {
    b [fillcolor=yellow style=filled]
    a -> b
    b -> c
    b -> d
}

Теперь у нас узел «b» покрашен желтым цветом. Для этого объявляется элемент в виде: b [fillcolor=yellow style=filled], то есть имя узла, и в квадратных скобках — атрибуты. Мы будем использовать «fillcolor» — цвет заливки, и «style=filled» — что бы вообще то заливать.

Можно таким образом указать много других атрибутов узла. В том числе «label», если нужно другая надпись в узле, не соответствующая синтаксису id узла. Описание есть на сайте. Мы пока только заливкой ограничимся.

Итак, поменяем нашу функцию формирования графика, добавив в нее определение параметров:

def create_graph(mig_map, app_color_map=dict()):
    res = ""
    res += "digraph G {\n"
    for itm in mig_map:
        # Выясним, нужен ли цвет
        app_color = app_color_map.get(itm[0][0])
        if app_color:
            # Добавим в диаграмму параметры параметры цвета к узлу
            res += '{} [fillcolor={} style=filled]\n'.format(node_name(itm[0]), app_color)
        # Впишем связь
        res += "{} -> {}\n".format(node_name(itm[0]), node_name(itm[1]))
    res += "}\n"
    return res

Первое — мы добавили параметр — карту раскраски. Если раскраска не нужна — можно второй параметр не передавать при вызове.

В цикле добавилось пару операторов. Во первых, мы из нашего словаря — карты раскраски пытаемся получить элемент с названием приложения, а он у нас в первом элементе первого элемента. И если такой элемент есть (нужна специальная раскраска) — то добавляем в текст диаграммы определение стиля для узла. Определение стиля не обязательно делать в начале текста диаграммы, но нужно до первого использования имени узла в схеме связей.

Маленькая тонкость. Я волюнтаристки здесь придумал, что цвет будет определятся по ПЕРВОМУ элементу связи графа. С одной стороны это позволяет уменьшить количество определений стиля для узла. Но с другой стороны — крайние миграции приложений не будут подсвечены. И при этом определение цвета может присутствовать не один раз в схеме — так как от одной и той же миграции может одновременно идти несколько.
Но это здесь не очень важно, и можно поправить самостоятельно.

Итак. Теперь запустим с новой возможностью:

app_color_map = {
    'powder': "yellow",
}
fname = "/home/alex/work/django/myproject/mig_map1.svg"
res = get_migrations_map()
grf = create_graph(res, app_color_map)
store_diag_svg(grf, fname)

Смотрим файл — Ура! Готово и с красивым выделением цветом нужных application.

А это — небольшой кусочек с одного из моих проектов. Для понимания:

Заключение

Итак. Мы разобрались как:

  • запускать проект Django в виде отдельного скрипта.

  • Динамически вычислять и импортировать модули миграций.

  • Собирать из них граф.

  • И строить красивую диаграмму используя сервер рендеринга kroki.io.

  • А так же делать POST запросы с использованием requests и получать результат в потоковой форме.

Может построение графа миграций и не такая актуальная вещь (но мне помогла поправить кое какие косяки), но рассмотренные приемы программирования и генерации диаграмм наверняка будут полезны.

Успехов Вам!

Комментарии (3)


  1. DosCervezasPorFavor
    00.00.0000 00:00

    Зачем тащить с собой миграции и разбираться в их зависимостях, а не сквошнуть? Практическая ценность, как история изменения схемы весьма сомнительна.

    По реализации не буду комментировать, позволю себе лишь заметить, что очень странно использовать сторонний сервис, генерируя и загоняя в него текстовый формат graphviz, а не сразу использовать его модуль для генерации той же svg.


    1. Alex_INS Автор
      00.00.0000 00:00

      Во первых по миграциям. Собственно с попытки сквошнуть все и началось. Джанга радостно взялась сжать миграции и радостно выдала ошибку - циклическая ссылка. Начал разбираться. А поскольку проект уже долгоживущий и файлов миграций несколько сотен - возникла необходимость разобраться со взаимозависимостями. В результате сжал часть миграций, которые не сильно пересекались между собой. Порядка 60 файлов сократил. Но понял, что в принципе это не имеет особого смысла. Потому как если Вы в проекте не занимались тем, что создали таблицу - удалили таблицу, создали поле - удалили поле, то сквош миграций по большому счету не уменьшает количества операций. А как следствие - не уменьшает времени выполнения миграций. Помочь может только замена ряда миграций на "create table". Но это уже сильно нужно все перелопачивать. И опять же - имея такую схему делать много проще.
      У меня этот проект мало того что долгоживущий, еще и установлен у пары десятков заказчиков в разных версиях, и обновляться они не спешат.

      Ну а по поводу использования внешнего сервера для диаграмм - я сказал в тексте. В этом смысле установить себе в качестве зависимости request более целесообразная вещь. А с помощью этого сервиса можно строить МНОГО РАЗНЫХ диаграмм, зная как.
      Я же пример показал. А далее - все в Ваших руках!


    1. forkhammer
      00.00.0000 00:00
      +1

      При сквоше полезно видеть зависимости миграций между приложухами. Без них можно нарваться на циклическую зависимость