Наверное странная идея — нарисовать диаграмму миграций проекта 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 вывода:
Мы поступим так же — будем читать все файлы миграций.
Не стоит оставлять в каталоге миграций мусор.
Идея чтения такова:
из регистра приложений проекта берем все подключенные приложения (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 и получать результат в потоковой форме.
Может построение графа миграций и не такая актуальная вещь (но мне помогла поправить кое какие косяки), но рассмотренные приемы программирования и генерации диаграмм наверняка будут полезны.
Успехов Вам!
DosCervezasPorFavor
Зачем тащить с собой миграции и разбираться в их зависимостях, а не сквошнуть? Практическая ценность, как история изменения схемы весьма сомнительна.
По реализации не буду комментировать, позволю себе лишь заметить, что очень странно использовать сторонний сервис, генерируя и загоняя в него текстовый формат graphviz, а не сразу использовать его модуль для генерации той же svg.
Alex_INS Автор
Во первых по миграциям. Собственно с попытки сквошнуть все и началось. Джанга радостно взялась сжать миграции и радостно выдала ошибку - циклическая ссылка. Начал разбираться. А поскольку проект уже долгоживущий и файлов миграций несколько сотен - возникла необходимость разобраться со взаимозависимостями. В результате сжал часть миграций, которые не сильно пересекались между собой. Порядка 60 файлов сократил. Но понял, что в принципе это не имеет особого смысла. Потому как если Вы в проекте не занимались тем, что создали таблицу - удалили таблицу, создали поле - удалили поле, то сквош миграций по большому счету не уменьшает количества операций. А как следствие - не уменьшает времени выполнения миграций. Помочь может только замена ряда миграций на "create table". Но это уже сильно нужно все перелопачивать. И опять же - имея такую схему делать много проще.
У меня этот проект мало того что долгоживущий, еще и установлен у пары десятков заказчиков в разных версиях, и обновляться они не спешат.
Ну а по поводу использования внешнего сервера для диаграмм - я сказал в тексте. В этом смысле установить себе в качестве зависимости request более целесообразная вещь. А с помощью этого сервиса можно строить МНОГО РАЗНЫХ диаграмм, зная как.
Я же пример показал. А далее - все в Ваших руках!
forkhammer
При сквоше полезно видеть зависимости миграций между приложухами. Без них можно нарваться на циклическую зависимость