Как большинство людей воспринимают чужие проекты
Как большинство людей воспринимают чужие проекты

Эта статья о разработке средства визуализации импортов внутри проекта на python, основное назначение которого построить полный граф связи скриптов между собой и с внешними библиотеками, основываясь только на статическом анализе AST дерева. Код не будет выполняться, а доступность библиотек — проверятся. Цель показать, что было задумано, а не как это будет работать в текущем окружении.

1. Анализ доступных инструментов и выработка требований

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

При беглом поиске подобных инструментов легко находятся pydeps, module-graph или snakefood.

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

  • python 3, причем с поддержкой версий 3.10+ (прощай snakefood);

  • анализ всех python скриптов в папке, даже тех, которые ничего не импортируют и не экспортируют (иногда файл в котором внутри только TODO: может сказать очень много);

  • обработка замысла разработчика, а не текущего состояния моего окружения. Импорт должен отображаться, даже если этого пакета больше не существует в природе (ну или внутри проекта класс был задуман, но не реализован, и он получился в принципе нерабочим в текущем состоянии);

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

2. Реализация требований

Мысли о текстовом парсере

Решив, что я смотрю на задумку разработчика, а не вариант реализации у меня в окружении, я даже поначалу попробовал написать парсер для обработки скрипта как текста. Что проще: нашел слово import, прочитал все что написано за ним. Или перед ним. Или на следующей строке, если на этой есть одиночная скобка. И еще надо понять, что слово import не входит в комментарий, прежде всего многострочный комментарий. Но реализовав процедуру, которая проверяет строку на принадлежность к комментарию, я понял, что разбираясь с объявлениями процедур и классов я утону в проверках редких, случаев когда в тексте есть непарные скобки и кавычки (например в регулярных выражениях, комментариях или просто ошибках синтаксиса). Прикинув, сколько нужно добавить обработчиков исключений, пришлось отказался от идеи текстового парсера. Разработчики python уже решили все эти проблемы за меня и завернули в удобный AST.

Анализ AST

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

В AST импорт описывается объектами классов ast.Import или ast.ImportFrom. Вместе с процедурами ast.parse и ast.walk они позволяют определить что должно импортироваться. Код по учебнику будет выглядеть примерно так:

Скрытый текст
with open(file_path, 'r', encoding='utf-8') as file:
    source_code = file.read()
# парсим ast
tree = ast.parse(source_code)
for node in ast.walk(tree):
    # Если простой импорт дабавляем как сесть
    if isinstance(node, ast.Import) or (node, ast.ImportFrom):
        for alias in node.names:
            print(alias.name)

Это элементарно, и задача не стоила бы выеденного яйца, если бы к третьей версии для обеспечения удобства разработчики python не сделали нормой относительный импорт (через точу или две) и регулярные пакеты, инициализируемые файлами __init__.py (RTFM import).

В этом месте все становится интереснее. Что бы определить импорт опираясь исключительно на статический анализ без фактического запуска интерпретатора в том или ином виде (это при условии что он не споткнется на недописанных/неверных кусках кода) необходимо предварительно собрать все необходимые сведения о файлах проекта: с одной стороны, что каждый из них хочет импортировать и откуда, а с другой стороны какие классы и процедуры он может экспортировать. А потом сопоставить их между собой и определить, где их желания и возможности могут совпасть.

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

Полный адрес файла python, как основное свойство, позволяющее отличить один экземпляр от другого self.full_path = full_path.

Признак того, что при парсинге файла произошли ошибки. Этот объект будет исключаться из обработки, но знать о его существовании мы должны self.have_errors = False.

Импорты. При этом множество импортов (в смысле множество, задаваемое командой set(), потому что импорты могут формироваться в блоках if-else или try-except, а потому дублироваться) должны быть приведены к единому виду. Я считаю, что это вид folder_name.folder_name.module_name.class_name мне так удобнее. Если это импорт вида import os, то будет просто os, если from pathlib import Path, то будет pathlib.Path, если from ..moduleZ import eggs, то subpackage2.moduleZ.eggs. Родительские папки и переходы будут устанавливаться в процессе формирования объекта.

Тогда при инициализации класса надо будет сделать self.importses = set(), при проходе по синтаксическому дереву (помните ast.walk(tree)?) надо будет выполнить:

Скрытый текст
# Если простой импорт добавляем как есть
if isinstance(node, ast.Import):
    for alias in node.names:
        self.importses.add(alias.name)
# если импорт сложный
elif isinstance(node, ast.ImportFrom):
    # Получение имен импортируемых элементов
    for alias in node.names:
        # если импорт через имя - формируем строку импорта
        if node.module:
            self.importses.add(node.module)
            self.importses.add(node.module + "." + alias.name)
        # если импорт через точки
        else:
            # идем по полному адресу модуля и формируем адрес импорта
            self.importses.add(self.full_path.split('/') [-node.level-1] + "." + alias.name)

Для определения классов и процедур в ast используются ast.FunctionDef и ast.ClassDef. Но ast.FunctionDef не делает различий между самостоятельными процедурами и методами классов. Опять придется делать все самим. Очевидно, методы класса не могут быть объявлены раньше самого класса. Поэтому если при обходе дерева мы встречаем ast.FunctionDef сам по себе, то это почти наверняка будет объявлением процедуры. А вот если после ast.ClassDef, то это уже метод класса. Но для надежности всем методам внутри класса будем добавлять атрибут parent. Тогда инициализировав список методов и классов self.class_funct_def = [] его можно будет наполнить как:

Скрытый текст
if isinstance(node, ast.FunctionDef):
    if not hasattr(node, "parent"):
        self.class_funct_def.append(node.name)
if isinstance(node, ast.ClassDef):
    # если функция формируется внутри функции внутри класса и не
    # получает self на вход, то она будет считаться самостоятельной
    # что не очень правильно, но конкретно здесь неважно
    for child in node.body:
        if isinstance(child, ast.FunctionDef):
            child.parent = node
    self.class_funct_def.append(node.name)
self.outside_impor = self.importses

И тут вспоминаем про регулярные пакеты и __init__.py. Это позволяет внутри проекта импортировать условный класс package.subpackage2.moduleZ.eggs и как from package import eggs и как from moduleZ import eggs и как from subpackage2 import eggs. Так что сформировав список методов и классов его надо будет расширить всеми возможными вариантами, которые могут быть представлены на стороне импорта. В нашем классе точно будет свойство с именем проекта, которое будет соответствовать имени верхней папки, в которой лежат скрипты python:

 # считаем что имя проекта последняя папка
self.project_name = full_path.split('/')[-2],

дополнительное множество (исключаем возможные повторения) self.name_to_export= set() и и отдельная процедура для решения этой задачи:

Скрытый текст
# формируем сет имен для экспорта
def gen_names_to_export(self):
    names_to_export = []
    buffer = ""
    # здесь просто перебираем все возможные сочетания вариантов обращения к модулю
    # и классам/процедурам в нем, полагая что файлы __init__ сформированы
    for nameses in self.module_name:
        names_to_export.append(nameses)
        names_to_export.append(self.project_name + '.' + nameses)
        if buffer:
            names_to_export.append(buffer + '.' + nameses)
            names_to_export.append(self.project_name + '.' + buffer + '.' + nameses)
        for defindes in self.class_funct_def:
            names_to_export.append(self.project_name + '.' + nameses + '.' + classeses)
            names_to_export.append(nameses + '.' + classeses)
            names_to_export.append(self.project_name + '.' + classeses)
            if buffer:
                names_to_export.append(self.project_name + '.' + buffer + '.' + nameses + '.' + classeses)
                names_to_export.append(nameses + '.' + buffer + '.' + classeses)
                names_to_export.append(self.project_name + '.' + buffer + '.' + classeses)
        if buffer:
            buffer = buffer + '.' + nameses
        else:
            buffer = nameses
    self.names_to_export = set(names_to_export).

Получившийся класс, не мудрствуя лукаво, назовем PyScript

class PyScript:
    def __init__(self, full_path)

и дальше инициализация и куски кода выше.

Сбор и обработка файлов

Имея базовые процедуры для класса можно начать работать с проектом в целом. Прежде всего собрать все файлы python в папке:

Скрытый текст
# собираем все файлы python в папке
def find_python_files(directory):
    py_paths = []
    for root, dirs, files in os.walk(directory):
        for file in files:
            if file.endswith('.py'):
                py_path = os.path.join(root, file)
                py_paths.append(py_path)
    return py_paths

И сформировать из каждого найденного файла объекты классов:

Скрытый текст
# формируем объекты из файлов py
def get_pyscript_obj(py_paths):
    py_scripts = []
    for py_path in py_paths:
        new_obj = PyScript(py_path, project_name)
        py_scripts.append(new_obj)
    return py_scripts

Собрав данные, начнем их последовательно сравнивать друг с другом, сопоставляя импорты и экспорты:

Скрытый текст
for scripts in py_scripts:
    # это нужно для разделения импортов внутри и за пределами проекта
    full_intersectes = set()
    if scripts.have_errors:
        print("error in file", scripts.full_path)
        continue
    for moduleses in py_scripts:
        if moduleses.have_errors:
            print("error in file", moduleses.full_path)
            continue
        # если есть пересечения в множестве имен модуля и импортируемого
        intersectes = scripts.names_to_export.intersection(moduleses.importses)
        if intersectes:
            print(f"Импорт из: {scripts.full_path} в {moduleses.full_path} объектов {intersectes}")
        # разделение внешних и внутренних импортов (следующий абзац)
        full_intersectes.update(moduleses.names_to_export). 

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

Скрытый текст
# уточняем внутренний и внешний импорт
def separate_import(self, full_intersectes):
    self.internal_import = full_intersectes.intersection(self.importses)
    self.external_import = self.importses.difference(self.internal_import)
    # если что-то импортируется непосредственно именем проекта
    self.external_import.discard(self.project_name)
    self.import_separated = True

Но есть, например, PyQt, который делиться на QtCore, QtGui и далее по списку. И внешний импорт может выглядеть как from PyQt.QtCore import QdataStrea, QEvent, QfileInfo. Здесь надо принять волевое решение и выбрать между подробным описанием и здравым смыслом в анализе по-крупному. Здравый смысл говорит, что для понимания проекта в целом достаточно знания, что импортируется сам большой пакет (тот же PyQt) и его крупные компоненты, конкретные классы разумнее смотреть в каждом файле. Поэтому в класс придется добавить и еще один метод:

Скрытый текст
# формируем строку того, что именно импортируется
def gen_external_import(self):
    grouped = {}
    # на всякий случай проверяем, что импорт уже разделен
    if self.import_separated:
        for item in self.external_import:
            # если внешний импорт содержит составное название 
            if '.' in item:
                # делим его по точке
                first_part, rest = item.split('.', 1)
                # первая часть это название пакета
                if first_part not in grouped:
                    # а если что-то было после точки
                    grouped[first_part] = []
                # это добавляем как часть словаря
                grouped[first_part].append(rest)
            else:
                if item not in grouped:
                    grouped[item] = []
        return grouped:
    else:
        return ""

и кусок кода для обработки внешних импортов в каждом файле:

Скрытый текст
scripts.separate_import(full_intersectes)
ext_import = scripts.gen_external_import()
# если он есть рисуем его
if ext_import:
    print(f"Внешние импорты: {ext_import}")

Построение графа и проверка

NetworkX я несколько раз пробовал притащить в проекты, но всегда откатываюсь к graphviz, наверное это дело вкусовщины.

Будем строить стандартный граф, где каждый файл проекта прямоугольник, в котором написано имя файла скрипта. Что бы разделить файлы проекта от внешних импортов, покрасим его границу в зеленый цвет (внешние импорты в красный). Поэтому dot = Digraph('wide') добавляем в начало скрипта, а из каждого проанализированного объекта PyScript будем формировать для него ноду dot.node(scripts.full_path, shape="box", color='green'). Если этот файл ни с кем не связан, нода будет «висеть в воздухе» справа от центра вверху.

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

Скрытый текст
# формируем строку того, что именно экспотируется
def gen_inner_export(self, intersectes):
    in_export = set()
    # смотрим какие имена совпали
    for item in intersectes:
        # смотрим какие процедуры и классы
        for definded in self.class_funct_def:
            if definded in item:
                in_export.add(definded)
    # если нет классов и процедур пишем «Все»
    if len(in_export) == 0:
        in_export = "all"
    else:
        in_export = '\n'.join(in_export)
    return in_export

Составляем процедуру для рисования ребра внутреннего импорта. Чтобы не запутаться в куче черных стрелочек (особенно в месте, где они собираются в пучек), придется добавить немного цветастости:

Скрытый текст
# добавляем вершину для внутреннего импорта и ребро:
def add_inner_edge(dot, scripts, moduleses, intersectes):
    dot.node(moduleses.full_path, moduleses.start_name, shape="box", color='green')
    # проводим между ними ребро с подписью что импортировали
    label = scripts.gen_inner_export(intersectes)
    # формируем случайный цвет потемнее
    color = f'#{random.randint(0, 150):02x}{random.randint(0, 150):02x}{random.randint(0, 150):02x}'
    dot.edge(scripts.full_path, moduleses.full_path, label=label, color=color, fontcolor=color)

Внешний импорт по сути работает также, но после формирования переменной ext_import (предыдущий раздел):

Скрытый текст
# добавляем вершину для внешнего импорта и ребро:
def add_external_edge(dot, ext_import, item, scripts):
    dot.node(item, item, shape="box", color='red')
    label = ext_import[item]
    # формируем случайный цвет посветлее
    color = f'#{random.randint(140, 220):02x}{random.randint(140, 220):02x}{random.randint(140, 220):02x}'
    dot.edge(item, scripts.full_path, label=label, color=color, fontcolor=color)

Чтобы избежать загруженности графа и растягивания его в колбасу, воспользуемся атрибутом 'ranksep'. На мой вкус надо добавлять дополнительный уровень на каждые 20 проанализированных файлов. Построение графа описывается всего тремя строками кода:

Скрытый текст
# формируем изображение, сохраняем в файл, показываем
dot.graph_attr['ranksep'] = str(len(py_scripts)/20)
dot.format = 'svg'
dot.render(filename=svg_file, view=True)

Дополнения по результатам испытаний

Анализировать свой проект плохая идея (я там все знаю и буду подгонять ответ). Поэтому в окружении были отобраны проекты, на 10-20 файлов (можно проверить руками), в которых есть подпапки с подпапками. Больше всего я работал с библиотекой mando (вот русскоязычные аллюзии здесь совсем ни при чем). Версия в моем окружении была как по заказу: импорт через ., .., * и имя папки.класса, и висящие в воздухе файлы.

Результат анализа проекта mando.
Результат анализа проекта mando.

По итогу тестирования стало понятно, что в квадратики надо писать не имя файла и не полный адрес, а классическое имя для импорта вида folder_name.folder_name.module_name. А еще можно сразу передавать новому объекту класса PyScript имя папки проекта при инициализации и использовать его для формирований удобного имени. Анализ больших проектов показал, что нужна возможность сделать картинку легче, убирая подписи к ребрам или внешний импорт, или все сразу.

Результат анализа проекта mando без внешних импортов и подписей.
Результат анализа проекта mando без внешних импортов и подписей.

Парсер аргументов командной строки

Для получения полноценного консольного приложения надо научить скрипт принимать аргументы из командной строки. Один из самых базовых инструментов это argparse. Он также позволяет легко добавить подсказку по использованию скрипта. Аргументов будет два обязательных — адрес папки с анализируемым проектом и имя файла для сохранения картинки, и два опциональных, чтобы спрятать подписи или внешние импорты. Таким образом, скрипт будет начинаться с обработки аргументов командной строки:

Скрытый текст
usage = """%(prog)s PROJECT_DIRECTORI SVG_FILENAME -e -l"""
description = "Анализ импорта внутри проекта и его графическое представление"
parser = argparse.ArgumentParser(usage=usage, description=description)
parser.add_argument('directory', type=str, help='Полный адрес папки проекта')
parser.add_argument('svg_file', type=str, help='Имя файла для сохранения графа')
parser.add_argument(
        "-e",
        "--no-external",
        action="store_true",
        default=False,
        dest="no_external",
        help="Скрыть внешний импорт",
    )
parser.add_argument(
        "-l",
        "--no-label",
        action="store_true",
        default=False,
        dest="no_label",
        help="Скрыть подписи к импорту",
    )
args = parser.parse_args()

Ну и пара процедур для проверки адекватности пользовательского ввода:

Скрытый текст
# проверка директории проекта
def check_directory(directory):
    directory = Path(directory)
    if not directory.exists():
        print(f"Ошибка: Папка '{directory}' не существует.")
        exit()
    elif not directory.is_dir():
        print(f"Ошибка: Путь '{directory}' не является папкой.")
        exit()
    return


# проверка на наличие доступа для записи файла картинки
def check_file_access(svg_file):
    try:
        with open(svg_file, 'a') as f:
            pass  # Просто открываем файл для записи
        return
    except PermissionError:
        print(f"Ошибка: Нет доступа для записи по пути '{svg_file}'.")
        exit()
    except Exception as e:
        print(f"Ошибка: {e}")
        exit()

Структура приложения в целом

Обобщая все принятые решения можно описать структуру приложения. Его основная часть это класс PyScript, который получает на вход адрес файла python и имя проекта. В классе инициализируются имя для отображения на графе self.start_name, множества для импортов в целом self.importses, импортов внутри проекта self.project_import и внешних импортов self.external_import, список объявляемых процедур и классов self.class_funct_def и множество всех возможных имен, которыми может быть представлен этот скрипт при импорте в других файлах проекта self.name_to_export. Есть флаги для ошибок при парсинге кода self.have_errors и завершения разделения импортов на внешний и внутренний self.import_separated. При инициализации объекта отдельными методами наполняются self.importses, self.class_funct_def и self.name_to_export. В процессе выполнения происходит обращение к методам separate_import, gen_inner_export и gen_external_import, которые позволяют разделить импорт на внутренний и внешний и сформировать подписи с конкретными импортируемыми объектами на ребрах в графе соответственно.

На старте приложение парсит аргументы командной строки, получая на вход адреса папки для обработки и имя файла для сохранения картинки, а также указания о необходимости показывать подписи к ребрам и внешние импорты. Проверяет адекватность ввода пользователя процедурами check_directory(directory) и check_file_access(svg_file). Затем формирует объекты PyScript, проходясь по папке с проектом (процедуры find_python_files(directory) и get_pyscript_obj(py_paths)). После этого сопоставляет все файлы проекта между собой, добавляя для каждого файла ноду, а при обнаружении импорта добавляет вторую ноду и ребро между ними с подписью add_inner_edge(dot, scripts, moduleses, intersectes, args). Пройдя по всем файлам вычленяет незадействованные импорты, признает их внешними и также добавляет на граф add_external_edge(dot, ext_import, item, args, scripts). И в конце концов строит граф. Полный текст приложения:

Скрытый текст
#!/usr/bin/python3
# -*- coding: utf-8 -*-

import os
import ast
from graphviz import Digraph
import random
import argparse
from pathlib import Path


class PyScript:
    def __init__(self, full_path, project_name=None):
        # Инициализация атрибутов
        self.full_path = full_path
        # Если есть проект
        if project_name:
            project_path = full_path.split(project_name, 1)[1]
            project_path = project_path[1:]
            self.project_name = project_name
        # если нет, считаем что имя проекта последняя папка
        else:
            project_path = full_path.split('/')[-2] + '/' + full_path.split('/')[-1]
            self.project_name = full_path.split('/')[-2]
        self.module_name = []
        # Убираем расширение
        self.start_name = project_path[:-3].replace('/', '.')
        # Разбиваем относительный адрес на части
        parts = self.start_name.split('.')
        # определяем, насколько глубоко модуль вложен
        self.module_level = len(parts)
        # добавляем все части имени
        self.module_name.extend(parts)
        # импорты
        self.importses = set()
        self.project_import = set()
        self.external_import = set()
        self.import_separated = False
        self.have_errors = False
        # классы и независимые функции
        self.class_funct_def = []
        # весь возможный набор имен для экспорта
        self.name_to_export = set()
        # наполняем импорты, а также классы и процедуры
        self.get_data(full_path)
        self.gen_names_to_export()

    # наполняем импорты, а также классы и процедуры
    def get_data(self, full_path):
        # читаем файл
        with open(full_path, 'r', encoding='utf-8') as file:
            source_code = file.read()
        # парсим ast
        try:
            tree = ast.parse(source_code)
        except Exception as e:
            print(e)
            self.have_errors = True
            return None
        for node in ast.walk(tree):
            # Если простой импорт добавляем как есть
            if isinstance(node, ast.Import):
                for alias in node.names:
                    self.importses.add(alias.name)
            # если импорт сложный
            elif isinstance(node, ast.ImportFrom):
                # получение имен импортируемых элементов
                for alias in node.names:
                    # если импорт через имя, то формируем строку импорта
                    if node.module:
                        self.importses.add(node.module)
                        self.importses.add(node.module + "." + alias.name)
                    # если импорт через точки
                    else:
                        # идем по полному адресу модуля и формируем адрес импорта
                        self.importses.add(self.full_path.split('/') [-node.level-1] + "." + alias.name)
            # здесь отделяем функции внутри класса от отдельных функций
            if isinstance(node, ast.FunctionDef):
                if not hasattr(node, "parent"):
                    self.class_funct_def.append(node.name)
            if isinstance(node, ast.ClassDef):
                # если функция формируется внутри функции внутри класса и не
                # получает self на вход, то она будет считаться самостоятельной
                # что не очень правильно, но конкретно здесь не важно
                for child in node.body:
                    if isinstance(child, ast.FunctionDef):
                        child.parent = node
                self.class_funct_def.append(node.name)
            self.outside_impor = self.importses
        return None

    # формируем сет имен для экспорта
    def gen_names_to_export(self):
        names_to_export = []
        buffer = ""
        # здесь просто перебираем все возможные сочетания вариантов обращения к модулю
        # и классам/процедурам в нем, полагая, что файлы __init__ сформированы
        for nameses in self.module_name:
            names_to_export.append(nameses)
            names_to_export.append(self.project_name + '.' + nameses)
            if buffer:
                names_to_export.append(buffer + '.' + nameses)
                names_to_export.append(self.project_name + '.' + buffer + '.' + nameses)
            for defindes in self.class_funct_def:
                names_to_export.append(self.project_name + '.' + nameses + '.' + classeses)
                names_to_export.append(nameses + '.' + classeses)
                names_to_export.append(self.project_name + '.' + classeses)
                if buffer:
                    names_to_export.append(self.project_name + '.' + buffer + '.' + nameses + '.' + classeses)
                    names_to_export.append(nameses + '.' + buffer + '.' + classeses)
                    names_to_export.append(self.project_name + '.' + buffer + '.' + classeses)
            if buffer:
                buffer = buffer + '.' + nameses
            else:
                buffer = nameses
        self.names_to_export = set(names_to_export)
        return None

    # формируем строку того, что именно экспортируется
    def gen_inner_export(self, intersectes):
        in_export = set()
        # смотрим какие имена совпали
        for item in intersectes:
            # смотрим какие процедуры и классы
            for definded in self.class_funct_def:
                if definded in item:
                    in_export.add(definded)
        # если их нет, то пишем "Все"
        if len(in_export) == 0:
            in_export = "Все"
        # иначе собираем в единую строку с переводом каретки
        else:
            in_export = '\n'.join(in_export)
        return in_export

    # уточняем внутренний и внешний импорт
    def separate_import(self, full_intersectes):
        self.internal_import = full_intersectes.intersection(self.importses)
        self.external_import = self.importses.difference(self.internal_import)
        # если что-то импортируется непосредственно именем проекта
        self.external_import.discard(self.project_name)
        self.import_separated = True
        return None

    # формируем строку того, что именно импортируется
    def gen_external_import(self):
        grouped = {}
        to_dots = {}
        # на всякий случай проверяем, что импорт уже разделен
        if self.import_separated:
            for item in self.external_import:
                # если внешний импорт содержит составное название
                if '.' in item:
                    # делим его по точке
                    first_part, rest = item.split('.', 1)
                    # первая часть это название пакета
                    if first_part not in grouped:
                        # а если что-то было после точки,
                        grouped[first_part] = []
                    # это добавляем как часть словаря
                    grouped[first_part].append(rest)
                else:
                    # или расширяем словарь, если этот пакет уже встречался
                    if item not in grouped:
                        grouped[item] = []
            # собираем все в единую строку с переводом каретки
            for item in grouped:
                to_dots[item] = '\n'.join(grouped[item])
            return to_dots
        else:
            return ""


# собираем все файлы python в папке
def find_python_files(directory):
    py_paths = []
    for root, dirs, files in os.walk(directory):
        for file in files:
            if file.endswith('.py'):
                py_path = os.path.join(root, file)
                py_paths.append(py_path)
    return py_paths


# генерируем имя проекта
def get_project_name(directory):
    # если в конце нет /, надо его добавить
    if directory[:-1] != "/":
        directory = directory + "/"
    return os.path.basename(os.path.dirname(directory))


# проверка директории проекта
def check_directory(directory):
    directory = Path(directory)
    if not directory.exists():
        print(f"Ошибка: Директория '{directory}' не существует.")
        exit()
    elif not directory.is_dir():
        print(f"Ошибка: Путь '{directory}' не является директорией.")
        exit()
    return


# проверка на наличие доступа для записи файла картинки
def check_file_access(svg_file):
    try:
        with open(svg_file, 'a') as f:
            pass  # Просто открываем файл для записи
        return
    except PermissionError:
        print(f"Ошибка: Нет доступа для записи по пути '{svg_file}'.")
        exit()
    except Exception as e:
        print(f"Ошибка: {e}")
        exit()


# формируем объекты из файлов py
def get_pyscript_obj(py_paths, project_name):
    py_scripts = []
    for py_path in py_paths:
        new_obj = PyScript(py_path, project_name)
        py_scripts.append(new_obj)
    return py_scripts


# добавляем вершину для внутреннего импорта и ребро:
def add_inner_edge(dot, scripts, moduleses, intersectes, args):
    dot.node(moduleses.full_path, moduleses.start_name, shape="box", color='green')
    # проводим между ними ребро с подписью что импортировали
    label = ""
    # если нет запрета рисовать подписи
    if not args.no_label:
        label = scripts.gen_inner_export(intersectes)
    # формируем случайный цвет потемнее
    color = f'#{random.randint(0, 150):02x}{random.randint(0, 150):02x}{random.randint(0, 150):02x}'
    dot.edge(scripts.full_path, moduleses.full_path, label=label, color=color, fontcolor=color)
    return None


# добавляем вершину для внешнего импорта и ребро:
def add_external_edge(dot, ext_import, item, args, scripts):
    dot.node(item, item, shape="box", color='red')
    label = ""
    # если нет запрета рисовать подписи
    if not args.no_label:
        label = ext_import[item]
    # формируем случайный цвет посветлее
    color = f'#{random.randint(140, 230):02x}{random.randint(140, 230):02x}{random.randint(140, 230):02x}'
    dot.edge(item, scripts.full_path, label=label, color=color, fontcolor=color)
    return None


usage = """%(prog)s PROJECT_DIRECTORI svg_FILENAME -e -l"""
description = "Анализ импорта внутри проекта и его графическое представление"
parser = argparse.ArgumentParser(usage=usage, description=description)
parser.add_argument('directory', type=str, help='Полный адрес папки проекта')
parser.add_argument('svg_file', type=str, help='Имя файла для сохранения графа')
parser.add_argument(
        "-e",
        "--no-external",
        action="store_true",
        default=False,
        dest="no_external",
        help="Скрыть внешний импорт",
    )
parser.add_argument(
        "-l",
        "--no-label",
        action="store_true",
        default=False,
        dest="no_label",
        help="Скрыть подписи к импорту",
    )
args = parser.parse_args()
directory = args.directory
check_directory(directory)
svg_file = args.svg_file
# Преобразование имени файла в абсолютный путь, если он задан без директории
if '/' not in svg_file and '\\' not in svg_file:
    svg_file = os.path.join(os.getcwd(), svg_file)
check_file_access(svg_file)
project_name = get_project_name(directory)
# находим все файлы python
py_paths = find_python_files(directory)
# объекты на основе каждого файла python
py_scripts = get_pyscript_obj(py_paths, project_name)
# формируем вершины и ребра графа
dot = Digraph('wide')
print("Анализируем файлы проекта")
for scripts in py_scripts:
    if scripts.have_errors:
        print("Ошибка в файле", scripts.full_path)
        continue
    print(scripts.full_path)
    # множество для последующего разделения импортов
    full_intersectes = set()
    # нода для текущего файла
    dot.node(scripts.full_path, scripts.start_name, shape="box", color='green')
    # получаем полный набор возможных комбинаций имен для импорта
    for moduleses in py_scripts:
        if moduleses.have_errors:
            print("Ошибка в файле", moduleses.full_path)
            continue
        # если есть пересечения в множестве имен модуля и импортируемого
        intersectes = scripts.names_to_export.intersection(moduleses.importses)
        if intersectes:
            # формируем дополнительные вершины и ребра
            add_inner_edge(dot, scripts, moduleses, intersectes, args)
        full_intersectes.update(moduleses.names_to_export)
    # формируем внешний импорт
    if not args.no_external:
        # если пользователь это не запретил
        scripts.separate_import(full_intersectes)
        ext_import = scripts.gen_external_import()
        # если он есть рисуем его
        if ext_import:
            for item in ext_import:
                add_external_edge(dot, ext_import, item, args, scripts)

# формруем изображение, сохраняем в файл, показываем
dot.graph_attr['ranksep'] = str(len(py_scripts)/20)
dot.format = 'svg'
dot.render(filename=svg_file, view=True)
exit() 

Выводы

Это приложение разработано как часть инструмента для статического анализа проектов на python, поэтому может местами казаться немного избыточным. В рамках оптимизации можно было бы добавить в класс флагов типа «здесь ничего нет, проходите мимо» на все случаи жизни, но учитывая высокую эффективность работы со строками, множествами и списками в python, а также запредельную вычислительную мощность появившихся последние лет 20 компьютеров, сокращать время анализа проекта с 500 мс до 300 мс не имеет смысла. Я не вижу сценария, в котором эти миллисекунды что-либо для кого-либо значили (время отрисовки графа graphviz для сложных проектов все равно всегда будет на порядки больше).

Регулировать глубину анализа подпапок, добавлять какие либо агрегирующие процедуры для упрощения вида графа путем объединения части скриптов я тоже не считаю целесообразным. В проекте сложности PyTorch или transformers разобраться самостоятельно за разумное время все таки не вариант. Здесь поможет только работа с уже задействованными в проекте людьми (ну или AGI, который разберет проект, а потом будет его разжевывать человеку, но, я думаю, он сам уже решит, как донести эти знания до тупого кожаного мешка).

В соавторы себе я взял Qwen3-14B, работающий локально. Я несколько раз попытался заставить его написать подобное приложение целиком, но даже режим думанья не позволяет ему решить такую задачу правильно. Поэтому пока это все еще помощник, сокращающий время написания стандартных процедур, а также иногда способный подсказать существование неизвестной тебе библиотеки. КДПВ: вариация на тему невозможных фигур по мотивам изображений Оскара Рутерсварда

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


  1. danilovmy
    20.09.2025 09:36

    @lexx0606 - стоило бы сначала посмотреть существующие анализаторы. Например мой dеpendency_extractor, или nanoapi c визуализатором. Можно тогда найти насколько подсказок что упущено:

    1. Есть такая штука в питоне, как Function Сomposition особенно если ее делать внутри класса (привет fastapi) или объявление класса внутри класса (привет модели django). В примере выше подобные функции будут помечены как методы класса. Хотя это не так.

    2. Пропущена обработка importlib. А это значит, что динамические импорты идут лесом. Библиотеки использующие importlib, вместо условно статических импортов, будут неправильно интерпретированы.

    3. Непонятно, что там с лямбдами, как атрибутами класса. Они будут превращены в методы?

    4. Тайпинг пропущен как таковой. Однако, если в тайпинге указать response: 'django.http.HttpResponse' вроде импорта и нет... а вроде зависимость есть... Зря, конечно, что такое возможно в Python, ну что есть - то есть.

    В общем, успехов автору. Все же, ИМХО, такой анализатор писать, используя llm в соавторах, пока рано. Лучше самому начать разбираться в Python, ну и расширять кругозор существующих аналогов тоже не помешает.