Хабрахабр

Автоматизация импортов в Python

Так получилось, что аж с 2012 года я разрабатываю open source браузерку, являясь единственным программистом. На Python само собой. Браузерка — штука не самая простая, сейчас в основной части проекта больше 1000 модулей и более 120 000 строк кода на Python. В сумме же с проектами-спутниками будет раза в полтора больше.

Так родилась библиотека smart_imports (github, pypi). В какой-то момент мне надоело возиться с этажами импортов в начале каждого файла и я решил разобраться с этой проблемой раз и навсегда.

Любой сложный проект со временем формирует собственное соглашение об именовании всего. Идея достаточно проста. Если это соглашение превратить в более формальные правила, то любую сущность можно будет импортировать автоматически по имени ассоциированной с ней переменной.

Например, не надо будет писать import math чтобы обратиться к math.pi — мы и так можем понять, что в данном случае math — модуль стандартной библиотеки.

5 Библиотека полностью покрыта тестами, coverage > 95%. Smart imports поддерживают Python >= 3. Сам пользуюсь уже год.

За подробностями приглашаю под кат.

Как оно работает в целом

Итак, код из заглавной картинки работает следующим образом:

  1. Во время вызова smart_imports.all() библиотека строит AST модуля, из которого сделан вызов;
  2. Находим неинициализированные переменные;
  3. Имя каждой переменной прогоняем через последовательность правил, которые пытаются по имени найти нужный для импорта модуль (или атрибут модуля). Если правило обнаружило необходимую сущность, следующие правила не проверяются.
  4. Найденные модули загружаются, инициализируются и помещаются в глобальное пространство имён (либо туда помещаются нужные атрибуты этих модулей).

Неинициализированные переменные ищутся во всех местах кода, включая новый синтаксис.

Кроме того, использование smart imports не запрещает использовать обычные импорты. Автоматическое импортирование включается только для тех компонентов проекта, которые явно вызывают smart_imoprts.all(). Это позволяет внедрять библиотеку постепенно, равно как и разрешать сложные циклические зависимости.

Дотошливый читатель заметит, что AST модуля конструируется два раза:

  • первый раз его строит CPython во в время импорта модуля;
  • второй раз его строит smart_imports во время вызова smart_imports.all().

AST действительно можно строить только один раз (для этого надо встроиться в процесс импорта модулей с помощью import hooks реализованных в PEP-0302, но такое решение замедляет импорт.

Как думаете, почему?

Сравнивая производительность двух реализаций (с хуками и без), я пришёл к выводу, что при импорте модуля CPython строит AST в своих внутренних (C-шных) структурах данных. Конвертация их в структуры данных Python выходит дороже, чем построение дерева по исходникам с помощью модуля ast.

Само собой, AST каждого модуля строится и анализируется только раз за запуск.

Правила импорта по-умолчанию

Библиотеку можно использовать без дополнительной конфигурации. По умолчанию она импортирует модули по следующим правилам:

  1. По точному совпадению имени ищет модуль рядом с текущим (в том же каталоге).
  2. Проверяет модули стандартной библиотеки:
    • по точному совпадению имени для пакетов верхнего уровня;
    • для вложенных пакетов и модулей проверяет составные имена с заменой точки на подчёркивание. Например os.path будет импортирован при наличии переменной os_path.
  3. По точному совпадению имени ищет установленные сторонние пакеты. Например общеизвестный пакет requests.

Производительность

Работа smart imports не сказывается на показателях работы программы, но увеличивает время её запуска.

5-2 раза. Из-за повторного построения AST время первого запуска увеличивается примерно в 1. В больших проектах же время запуска страдает скорее от структуры зависимостей между модулями, чем от времени импорта конкретного модуля. Для малых проектов это несущественно.

Когда если smart imports станут популярными, перепишу работу с AST на C — это должно заметно снизить издержки при запуске.

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

Так как некоторые правила используют стандартную функциональность Python для поиска модулей. На время запуска влияет как перечень правил поиска модулей, так и их последовательность. далее). Исключить эти расходы можно явно указав соответствие имён и модулей с помощью правила «Кастомизированные имена» (см.

Конфигурация

Дефолтная конфигурация была описана ранее. Её должно хватать для работы со стандартной библиотекой в небольших проектах.

Дефолтный конфиг

, {"type": "rule_stdlib"}, {"type": "rule_predefined_names"}, {"type": "rule_global_modules"}]
}

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

Пример сложного конфига (из браузерки).

Если такой файл найден, он считается конфигурацией для текущего модуля. Во время вызова smart_import.all() библиотека определяет положение вызывающего модуля на файловой системе и начинает искать файл smart_imports.json по направлению от текущего каталога к корневому.

Можно использовать несколько разных конфигов (разместив их в разных каталогах).

Параметров конфигурации сейчас не так много:

{ // Каталог для хранения кэша AST. // Если не указан или null — кэш не используется. "cache_dir": null|"string", // Список конфигов правил в порядке их применения. "rules": []
}

Правила импорта

Порядок указания правил в конфиге определяет порядок их применения. Первое сработавшее правило останавливает дальнейший поиск импортов.

В примерах конфигов далее будет часто фигурировать правило rule_predefined_names, оно необходимо чтобы корректно распознавались встроенные функции (например, print).

Правило 1: Предопределённые имена

Правило позволяет игнорировать предопределённые имена вроде __file__ и встроенные функции, например print.

Пример

# конфиг:
# {
# "rules": [{"type": "rule_predefined_names"}]
# } import smart_imports smart_imports.all() # мы не будем искать модуль с именем __file__
# хотя в коде эта переменная не проинициализирована
print(__file__)

Правило 2: Локальные модули

Проверяет, есть ли рядом с текущим модулем (в том же каталоге) модуль с указанным именем. Если есть, импортирует его.

Пример

# конфиг:
# {
# "rules": [{"type": "rule_predefined_names"},
# {"type": "rule_local_modules"}]
# }
#
# код на файловой системе:
#
# my_package
# |-- __init__.py
# |-- a.py
# |-- b.py # b.py
import smart_imports smart_imports.all() # Будет импортирован модуль "a.py"
print(a)

Правило 3: Глобальные модули

Пробует импортировать модуль непосредственно по имени. Например, модуль requests.

Пример

# конфиг:
# {
# "rules": [{"type": "rule_predefined_names"},
# {"type": "rule_global_modules"}]
# }
#
# ставим дополнительный пакет
#
# pip install requests import smart_imports smart_imports.all() # Будет импортирован модуль requests
print(requests.get('http://example.com'))

Правило 4: Кастомизированные имена

Соотносит с именем конкретный модуль или его атрибут. Соответствие указывается в конфиге правила.

Пример

# конфиг:
# {
# "rules": [{"type": "rule_predefined_names"},
# {"type": "rule_custom",
# "variables": {"my_import_module": {"module": "os.path"},
# "my_import_attribute": {"module": "random", "attribute": "seed"}}}]
# } import smart_imports smart_imports.all() # В примере исплользованы модули стандартной библиотеки
# Но аналогично можно импортировать любой другой модуль
print(my_import_module)
print(my_import_attribute)

Правило 5: Стандартные модули

Проверяет, не является ли имя модулем стандартной библиотеки. Например math или os.path который трансформируется в os_path.

Списки для каждой версии Python берутся отсюда: github.com/jackmaney/python-stdlib-list Работает быстрее чем правило импорта глобальных модулей, так как проверяет наличие модуля по закэшированному списку.

Пример

# конфиг:
# {
# "rules": [{"type": "rule_predefined_names"},
# {"type": "rule_stdlib"}]
# } import smart_imports smart_imports.all() print(math.pi)

Правило 6: Импорт по префиксу

Импортирует модуль по имени, из пакета, ассоциированного с его префиксом. Удобно использовать, когда у вас есть несколько пакетов использующихся во всём коде. Например к модулям пакета utils можно обращаться с префиксом utils_.

Пример

# конфиг:
# {
# "rules": [{"type": "rule_predefined_names"},
# {"type": "rule_prefix",
# "prefixes": [{"prefix": "utils_", "module": "my_package.utils"}]}]
# }
#
# код на файловой системе:
#
# my_package
# |-- __init__.py
# |-- utils
# |-- |-- __init__
# |-- |-- a.py
# |-- |-- b.py
# |-- subpackage
# |-- |-- __init__
# |-- |-- c.py # c.py import smart_imports smart_imports.all() print(utils_a)
print(utils_b)

Правило 7: Модуль из родительского пакета

Если у вас есть одноимённые субпакеты в разных частях проекта (например, tests или migrations), для них можно разрешить искать модули для импорта по имени в родительских пакетах.

Пример

# конфиг:
# {
# "rules": [{"type": "rule_predefined_names"},
# {"type": "rule_local_modules_from_parent",
# "suffixes": [".tests"]}]
# }
#
# код на файловой системе:
#
# my_package
# |-- __init__.py
# |-- a.py
# |-- tests
# |-- |-- __init__
# |-- |-- b.py # b.py import smart_imports smart_imports.all() print(a)

Правило 8: Привязка к другому пакету

Для модулей из конкретного пакета разрешает поиск импортов по имени в других пакетах (указанных в конфиге). В моём случае это правило оказалось полезным для случаев, когда не хотелось распространять работу предыдущего правила (Модуль из родительского пакета) на весь проект.

Пример

# конфиг:
# {
# "rules": [{"type": "rule_predefined_names"},
# {"type": "rule_local_modules_from_namespace",
# "map": {"my_package.subpackage_1": ["my_package.subpackage_2"]}}]
# }
#
# код на файловой системе:
#
# my_package
# |-- __init__.py
# |-- subpackage_1
# |-- |-- __init__
# |-- |-- a.py
# |-- subpackage_2
# |-- |-- __init__
# |-- |-- b.py # a.py import smart_imports smart_imports.all() print(b)

Добавление собственных правил

Добавить собственное правило довольно просто:

  1. Наследуемся от класса smart_imports.rules.BaseRule.
  2. Реализуем нужную логику.
  3. Регистрируем правило с помощью метода smart_imports.rules.register
  4. Добавляем правило в конфиг.
  5. ???
  6. Профит.

Пример можно найти в реализации текущий правил

Профит

Пропали многострочные списки импортов в начале каждого исходника.

До перехода браузерки на smart imports в ней было 6688 строк отвечающих за импорт. Cократилось количество строк. После перехода осталось 2084 (по две строки smart_imports на каждый файл + 130 импортов, вызываемых явно из функций и подобных мест).

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

Планы развития

Идея определять свойства кода по именам переменных мне нравится, поэтому буду пробовать развивать её как в рамках smart imports, так и в рамках других проектов.

Касательно smart imports, планирую:

  1. Добавлять поддержку новых версий Python.
  2. Исследовать возможность опереться на текущие наработки сообщества по аннотации кода типами.
  3. Исследовать возможность сделать ленивые импорты.
  4. Реализовать утилиты для автоматической генерации конфига по исходникам и рефакторингу исходников на использование smart_imports.
  5. Переписать часть кода на C, чтобы ускорить работу с AST.
  6. Развивать интеграцию с линтерами и IDE, если у тех будут возникать проблемы с анализом кода без явных импортов.

Кроме того, мне интересно ваше мнение по поводу дефолтного поведения библиотеки и правил импорта.

Спасибо что осилили эту простыню текста 😀

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть