Первое погружение в статический анализ кода своими руками

Одна из особенностей программной инженерии как профессии - это возможность ars-poetic работы: часть нашей работы - создание инструментов, нацеленных на нашу собственную работу; возможно, несколько хирургов по всему миру смогут спроектировать и создать свой собственный скальпель, но для инженеров-программистов создание собственных инструментов - повседневная реальность.

Недавно я работал над переносом большого репозитория кода, который будет построен с помощью Bazel. Чтобы сделать это правильно, мне пришлось сгенерировать более сотни различных файлов BUILD. Выполнение этого вручную было бы медленным, подверженным ошибкам и утомительным занятием, поэтому вместо этого я решил написать инструмент, который будет делать это автоматически за меня.

При описании модуля BUILD для Bazel каждый тестовый файл должен быть определен как правило *_test, которое явно определяет любые его зависимости. Например, чтобы заставить Bazel запустить тест на Python, нужно написать что-то вроде:

py_test(
    name = "test_context",
    srcs = ["test_context.py"],
    main = "test_context.py",
    tags = [],
    deps = [
        ":context",
        "//libs/config",
    ],
)

Первый шаг к написанию программы, которая будет генерировать этот блок для каждого тестового файла в нашем репозитории, - это выяснить, какие из файлов в нашем репозитории являются тестовыми файлами. В контексте репозитория я работал в тестовом файле можно определить как:

  1. У файла есть суффикс *.py
  2. Файл содержит класс, унаследованный от unittest.TestCase
  3. В классе есть хотя бы один метод, имя которого начинается с test_.

Использование AST для развлечения и прибыли

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

Оказывается, есть простой способ сделать это: стандартная библиотека Python содержит аккуратный небольшой пакет с именем ast - сокращение от Abstract Syntax Tree. Википедия определяет AST как:

В информатике абстрактное синтаксическое дерево (AST) или просто синтаксическое дерево - это древовидное представление абстрактной синтаксической структуры исходного кода, написанного на языке программирования. Каждый узел дерева обозначает конструкцию, встречающуюся в исходном коде.

а ast пакетная документация описывает это как:

Модуль ast помогает приложениям Python обрабатывать деревья грамматики абстрактного синтаксиса Python. Сам абстрактный синтаксис может меняться с каждым выпуском Python; этот модуль помогает программно узнать, как выглядит текущая грамматика.

В разработке программного обеспечения практика создания программ, которые анализируют исходный код другой программы без его фактического выполнения, называется статическим анализом кода. Используя анализатор AST для интересующего нас языка, мы преобразуем этот исходный код. в данные, которые мы можем обрабатывать так же, как и любые другие. Используя пакет ast стандартной библиотеки Python, мы можем проанализировать блок кода Python в структуру данных, которую мы можем просматривать и анализировать. Это будет очень удобно при ответе на вопрос, содержит ли файл модульный тест!

Поиск всех тестовых файлов в репо

Первый шаг - пройти по файловой системе, чтобы найти все файлы-кандидаты:

import os

def is_test_file(path) -> bool:
	# TODO: impl
	pass

def find_test_files(repo_root):
	test_files = []
	for root, dirs, files in os.walk(repo_root):
		for file in files:
			if not file.endswith(".py"):
				continue
			path = os.path.join(root, file)
			if is_test_file(path):
				test_files.append(path)
	return test_files

Теперь давайте посмотрим, что мы можем сделать с ast.

Python 3.7.7 (default, Jul 15 2020, 21:51:02)
[Clang 10.0.1 (clang-1001.0.46.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> source = """
... class Test(unittest.TestCase):
...   def test_hello(self):
...     pass
... """
>>> import ast
>>> tree = ast.parse(source)
>>> print(tree)
<_ast.Module object at 0x10379c6d0>

Используя этот аккуратный визуализатор, вот как выглядит AST Python:

Поэтому при синтаксическом анализе блока исходного кода с помощью ast.parse мы получаем древовидную структуру данных, которая имеет:

  • Module в корне
  • С атрибутом body, который представляет собой список элементов
  • В нашем файле есть только один элемент ClassDef объект с name из Test
  • Этот ClassDef имеет два атрибута: body и bases.
  • body имеет единственный FunctionDef (определение нашего метода тестирования), имеющий имя test_hello
  • bases - это список базовых классов, которые наш ClassDef наследует, в нашем случае, то, что, если немного прищуриться, мы можем увидеть unittest.TestCase

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

import ast

def is_test_file(abspath) -> bool:
  with open(abspath, "r") as f:
      data = f.read()
  source_ast = ast.parse(data)

  for node in source_ast.body:
    if isinstance(node, ast.ClassDef) and _is_testcase_class(node):
        for class_node in node.body:
            if isinstance(class_node, ast.FunctionDef) and class_node.name.startswith("test_"):
                return True
	  return False

def _is_testcase_class(self, classdef: ast.ClassDef) -> bool:
  for base_class in classdef.bases:
				
  # if the test class looks like class Test(TestCase)
  if isinstance(base_class, ast.Name):
      base_name = base_class.id

  # if the test class looks like class Test(unittest.TestCase):
  elif isinstance(base_class, ast.Attribute):
      base_name = base_class.attr
  else:
      continue
  if base_name == 'TestCase':
      return True
  return False

Короче:

  • Мы читаем исходный файл в строку, затем анализируем ее с помощью ast.parse
  • Затем мы сканируем тело модуля в поисках узлов ast.ClassDef (определение класса).
  • Если мы его находим, мы проверяем, наследуется ли он от unittest.TestCase, глядя на атрибут bases определения класса.
  • Если мы находим класс TestCase, мы перебираем body этого ClassDef в поисках FunctionDef чье имя начинается с test_

Вывод

Конечно, мы, вероятно, могли бы получить хорошие результаты, используя сценарий оболочки с некоторыми регулярными выражениями на основе эвристики, но используя AST для анализа исходного кода так, как его видит интерпретатор, мы можем получить окончательные ответы, устойчивые к много крайних случаев, таких как странное форматирование и размещение кода внутри комментариев или строк. Возможность создавать инструменты, облегчающие вашу работу, - одна из лучших вещей в работе инженера-программиста. Возможность программного синтаксического анализа и анализа собственного исходного кода выводит ваши возможности создания инструментов на новый уровень!

Первоначально опубликовано на https://rotemtam.com 13 августа 2020 г.