Візуалізація клонів в проекті на Python


Нещодавно в нашому проекті потрібно налаштувати моніторинг якості коду. Якість коду — поняття суб'єктивне, однак давним-давно придумали безліч показників, що дозволяють провести мало-мальськи кількісний аналіз. Приміром, цикломатическая складність або індекс поддерживаемости (maintainability index). Вимірювання подібного роду показників — звичайна справа для мов на кшталт Java або C++, однак складається враження) в питоньем співтоваристві рідко коли хто-то про це замислюється. На щастя, існує чудовий radon xenon-ом, який швидко і якісно обчислює згадані вище метрики і навіть деякі інші. Звичайно, для професійних enterprise інструментів замало, але все необхідне є.

Крім обчислення метрик, буває також корисно провести аналіз залежностей. Якщо в проекті задекларована архітектура, між окремими частинами повинні існувати певні зв'язки. Найбільш частий приклад: додаток побудовано навколо бібліотеки, що надає API, і вельми небажано виконувати дії в обхід цього API. Іншими словами, недобре ioctl-ить в ядро коли libc є. Для пітона є кілька пакетів, будують граф залежностей між модулями, і snakefood здався мені самим вдалим.

Крім аналізу залежностей, не менш корисно визначати копипасту, особливо, якщо у проекті задіяні джуниоры або інші люди, люблячі «зрізати кути болгаркою». Про це власне і піде мова в статті.

clonedigger
Напевно науці відомі комерційні інструменти визначення копипасты для Python, але основним критерієм вибору була безкоштовність. Перше ж посилання в пошуковику вивела на clonedigger. За цей чудовий пакет дякуємо peter_bulychev. статті 6-річної давності можна подивитися презентації, в ній знаходиться опис алгоритму і переповідати його сенсу немає. Найважливіше з прикладної точки зору: pip install clonedigger, підтримки трійки немає, 3 року не оновлювався, є дохлий форк на гітхабі. Ну та гаразд! На 2.7.8 працює нормально, а мій проект все одно наскрізь просякнутий six-ом.

Дигер являє собою однойменну консольну утиліту, якій на вхід подаються опції і шлях до кореня піддослідного проекту. Вміє випльовувати машиночитаемый XML за схемою CPD, якщо передати --cpd-output. Тим самим робить щасливим Violations Plugin в Jenkins-е.
Прихований текстЯкщо подивитися список мов, з якими працює «don't shoot the messenger», в очі відразу впадає несправедливість: всякі PHP є, а Python-а ні! І так з багатьма інструментами. Звідси і ремарка на початку статті про співтовариство.
Також у clonedigger є супер крута фіча «не сканувати обрані директорії» (--ignore-dir), що дозволяє не червоніти за говнокод в тестах виключити з аналізу сторонній код. Правда, реалізована вона самобутньо:

def walk(dirname):
for dirpath, dirs, files in os.walk(file_name):
dirs[:] = (not options.ignore_dirs and dirs) or [d for d in dirs if d not in options.ignore_dirs]
...

Пояснення: виключаються не відносні шляхи, а імена. Передаючи, наприклад, «ext», ви виключите разом і «root/ext», і «root/foo/bar/ext», і «root/tests/ext» — довелося витратити деякий час, щоб це усвідомити, і навіть залізти в исходники.

Отже, після завершення роботи діггера з потрібною опцією з'явиться XML з знайденими клонами. Структура приблизно така:

<pmd-cpd>
<duplication lines="13" tokens="40">
<file line="853" path="tornado/auth.py"/>
<file line="735" path="tornado/auth.py"/>
<codefragment>
<![CDATA[
def _on_friendfeed_request(self, future, response):
if response.error:
future.set_exception(AuthError(
"Error response %s fetching %s" % (response.error,
response.request.url)))
return
future.set_result(escape.json_decode(response.body))
def _oauth_consumer_token(self):
self.require_setting("friendfeed_consumer_key", "FriendFeed OAuth")
self.require_setting("friendfeed_consumer_secret", "FriendFeed OAuth")
return dict(
key=self.settings["friendfeed_consumer_key"],
secret=self.settings["friendfeed_consumer_secret"])
]]>
</codefragment>
</duplication>
<duplication>
...
</pmd-cpd>

Здорово, коли можна в будь-який момент отримати список клонів в CI, але для моніторингу, мабуть, недостатньо. Немає горезвісної картинки, дивлячись на яку, менеджер проекту зможе скласти думку про масштаб лиха.

Візуалізація
Представляю на суд громадськості скрипт для відображення величини взаємної копипасты в модулях проекту. На вхід подаються імена двох файлів — XML від clonedigger і створюється зображення. Залежності: matplotlib, scipy, xmltodict, cairo. Алгоритм роботи:
  1. Розпарсити cpd
  2. Побудувати матрицю величини клонування між модулями
  3. Кластеризовать модулі по зворотної матриці (тобто, по матриці відстані між файлами)
  4. Застосувати знайдений порядок проходження модулів до вихідної матриці
  5. Довго і нудно малювати на matplotlib-е
Парсинг
with open(sys.argv[1], 'r') as fin:
data = xmltodict.parse(fin.read())

Парсинг по суті виконується в один рядок моїм улюбленим xmltodict-ом: ніякого SAX, ніяких знань xml, це навіть простіше ніж XDocument в шарпа. Якщо xmltodict зустрічає кілька однакових позначок на одному рівні, то він створює масив, а атрибути відрізняються від вкладених елементів "@" на початку імені. Звичайно, це не найшвидший метод не універсальний, але в даному випадку працює на всі сто.

Матриця клонів
Далі отримуємо список унікальних шляхів і будуємо індекс:
files = list(sorted(set.union({dup['file'][i]['@path']
for dup in data['pmd-cpd']['duplication']
for i in (0, 1)})))
findex = {f: i for i, f in enumerate(files)}

Пробігаємо распарсенному дереву і будуємо трикутну матрицю, в комірках якої лежать підсумовані кількості рядків у знайдених клонах:

mat = numpy.zeros((len(files), len(files)))
for dup in data['pmd-cpd']['duplication']:
mat[tuple(findex[dup['file'][i]['@path']] for i in(0, 1))] += \
int(dup['@lines'])

Додаємо до нашої трикутної матриці таку ж, але транспоновану, тим самим створюємо повноцінну матрицю:

mat += mat.transpose()

Кластеризація
Якщо прямо зараз намалювати нашу матрицю, буде не дуже зрозуміло, які групи файлів копіюють один одного. У разі одиничних пар все ясно, але клони мають мерзенне властивість тягнутися відразу у багато модулів одночасно, наприклад, із-за неякісного рефакторінгу. Тому краще спочатку згрупувати файли по схожості між собою, формуючи квадратні попарно непересічні області. Строго кажучи, якщо модуль A схожий на модуль B, а B схожий на C, то це ще зовсім не означає, що A схожий на C (відношення не транзитивно), однак дуже часто це саме так.

Будуємо матрицю відстаней як обернену до матриці клонування, не забуваючи, що ділити на нуль не можна, потім кластеризуем:

mat[mat == 0] = 0.001
order = leaves_list(linkage(1 / mat))

Ось за це я люблю scipy! Одна рядок, а як багато всередині! До речі, замість linkage можна спробувати й інший метод з доступних. Ах так, кластеризація повинна бути ієрархічною (див., наприклад, ось цю статтю що це таке), тому що ми хочемо впорядкувати файли (функція leaves_list). Якщо захочете самі погратися, зручно використовувати dendrogram для відображення результуючої ієрархії.

Застосовуємо знайдений порядок до імен файлів і матриці:

mat = mat[numpy.ix_(order, order)]
files = [files[i] for i in order]

Образотворче мистецтво
Я не спец з наукової візуалізації, і код зібрав на коліні, використовуючи старий добрий stackoverflow driven development. Для початку виберемо палітру у відтінках червоного і білого:

cdict = {'red': ((0.0, 1.0, 1.0),
(1.0, 1.0, 1.0)),
'green': ((0.0, 1.0, 1.0),
(1.0, 0.0, 0.0)),
'blue': ((0.0, 1.0, 1.0),
(1.0, 0.0, 0.0))}
reds = LinearSegmentedColormap('Reds', cdict)

Можна вибрати будь-яку іншу з колекції matplotlib.cm. Далі створюємо фігуру і осі і довго їх поліруємо напилком:

fig = pyplot.figure()
ax = fig.add_subplot(111)
ax.pcolor(mat, cmap=reds)
# uncomment the following to remove the frame around the map
# ax.set_frame_on(False)
ax.set_xlim((0, len(files)))
ax.set_ylim((0, len(files)))
ax.set_xticks(numpy.arange(len(files)) + 0.5, minor=False)
ax.set_yticks(numpy.arange(len(files)) + 0.5, minor=False)
ax.invert_yaxis()
ax.xaxis.tick_top()
ax.set_xticklabels([os.path.basename(f) for f in files], minor=False,
rotation=90)
ax.set_yticklabels([os.path.basename(f) for f in files], minor=False)
ax.grid(False)
ax.set_aspect(1)
for t in ax.xaxis.get_major_ticks():
t.tick1On = False
t.tick2On = False
for t in ax.yaxis.get_major_ticks():
t.tick1On = False
t.tick2On = False

Як бачите, імена файлів беруться базові, без шляху, тому що інакше для гіллястих проектів не будуть поміщатися в області для малювання. Ок, залишилося зовсім мало: встановити адекватний розмір і власне виконати рендер:

fig_size = 16 * len(files) / 55
fig.set_size_inches(fig_size, fig_size)
pyplot.savefig(sys.argv[2], bbox_inches='tight', transparent=False, dpi=100,
pad_inches=0.1)

Розмір підібраний на око, щоб імена файлів не злипалися. Формат файлу визначається по його розширенню автоматично, як мінімум, cairo підтримує png і svg.

Тестування
Для демонстрації я взяв три відкритих проекту: tornado, matplotlib і twisted. З аналізу були виключені тести. До речі, КДПВ — лівий верхній кут з twisted.


tornado


matplotlib


twisted

Як бачимо, у всіх проектів простежується головна діагональ, тобто найчастіше копіюють файли самих себе. Можливо, це пов'язано з особливостями мови, можливо, з відсутністю макросів. Тим не менш, яскраві червоні точки заслуговують пильної уваги і є кандидатами на рефакторинг — доведено нашими власними проектами.

Буду радий зауважень і виправлень, дякую за увагу.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.