До | После |
---|---|
|
|
Так получилось, что аж с 2012 года я разрабатываю open source браузерку, являясь единственным программистом. На Python само собой. Браузерка — штука не самая простая, сейчас в основной части проекта больше 1000 модулей и более 120 000 строк кода на Python. В сумме же с проектами-спутниками будет раза в полтора больше.
В какой-то момент мне надоело возиться с этажами импортов в начале каждого файла и я решил разобраться с этой проблемой раз и навсегда. Так родилась библиотека smart_imports (github, pypi).
Идея достаточно проста. Любой сложный проект со временем формирует собственное соглашение об именовании всего. Если это соглашение превратить в более формальные правила, то любую сущность можно будет импортировать автоматически по имени ассоциированной с ней переменной.
Например, не надо будет писать import math
чтобы обратиться к math.pi
— мы и так можем понять, что в данном случае math
— модуль стандартной библиотеки.
Smart imports поддерживают Python >= 3.5 Библиотека полностью покрыта тестами, coverage > 95%. Сам пользуюсь уже год.
За подробностями приглашаю под кат.
Как оно работает в целом
Итак, код из заглавной картинки работает следующим образом:
- Во время вызова
smart_imports.all()
библиотека строит AST модуля, из которого сделан вызов; - Находим неинициализированные переменные;
- Имя каждой переменной прогоняем через последовательность правил, которые пытаются по имени найти нужный для импорта модуль (или атрибут модуля). Если правило обнаружило необходимую сущность, следующие правила не проверяются.
- Найденные модули загружаются, инициализируются и помещаются в глобальное пространство имён (либо туда помещаются нужные атрибуты этих модулей).
Неинициализированные переменные ищутся во всех местах кода, включая новый синтаксис.
Автоматическое импортирование включается только для тех компонентов проекта, которые явно вызывают smart_imoprts.all()
. Кроме того, использование smart imports не запрещает использовать обычные импорты. Это позволяет внедрять библиотеку постепенно, равно как и разрешать сложные циклические зависимости.
Дотошливый читатель заметит, что AST модуля конструируется два раза:
- первый раз его строит CPython во в время импорта модуля;
- второй раз его строит smart_imports во время вызова
smart_imports.all()
.
AST действительно можно строить только один раз (для этого надо встроиться в процесс импорта модулей с помощью import hooks реализованных в PEP-0302, но такое решение замедляет импорт.
Само собой, AST каждого модуля строится и анализируется только раз за запуск.
Правила импорта по-умолчанию
Библиотеку можно использовать без дополнительной конфигурации. По умолчанию она импортирует модули по следующим правилам:
- По точному совпадению имени ищет модуль рядом с текущим (в том же каталоге).
- Проверяет модули стандартной библиотеки:
- по точному совпадению имени для пакетов верхнего уровня;
- для вложенных пакетов и модулей проверяет составные имена с заменой точки на подчёркивание. Например
os.path
будет импортирован при наличии переменнойos_path
.
- По точному совпадению имени ищет установленные сторонние пакеты. Например общеизвестный пакет requests.
Производительность
Работа smart imports не сказывается на показателях работы программы, но увеличивает время её запуска.
Из-за повторного построения AST время первого запуска увеличивается примерно в 1.5-2 раза. Для малых проектов это несущественно. В больших проектах же время запуска страдает скорее от структуры зависимостей между модулями, чем от времени импорта конкретного модуля.
Когда если smart imports станут популярными, перепишу работу с AST на C — это должно заметно снизить издержки при запуске.
Для ускорения загрузки, результаты обработки AST модулей можно кэшировать на файловой системе. Включается кэширование в конфиге. Само собой, кэш инвалидируется при изменении исходников.
На время запуска влияет как перечень правил поиска модулей, так и их последовательность. Так как некоторые правила используют стандартную функциональность Python для поиска модулей. Исключить эти расходы можно явно указав соответствие имён и модулей с помощью правила «Кастомизированные имена» (см. далее).
Конфигурация
Дефолтная конфигурация была описана ранее. Её должно хватать для работы со стандартной библиотекой в небольших проектах.
{
"cache_dir": null,
"rules": [{"type": "rule_local_modules"},
{"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)
Добавление собственных правил
Добавить собственное правило довольно просто:
- Наследуемся от класса smart_imports.rules.BaseRule.
- Реализуем нужную логику.
- Регистрируем правило с помощью метода smart_imports.rules.register
- Добавляем правило в конфиг.
- ???
- Профит.
Пример можно найти в реализации текущий правил
Профит
Пропали многострочные списки импортов в начале каждого исходника.
Cократилось количество строк. До перехода браузерки на smart imports в ней было 6688 строк отвечающих за импорт. После перехода осталось 2084 (по две строки smart_imports на каждый файл + 130 импортов, вызываемых явно из функций и подобных мест).
Приятным бонусом оказалась стандартизация имён в проекте. Код стало легче читать и легче писать. Пропала необходимость думать над именами импортируемых сущностей — есть несколько чётких правил, которым просто следовать.
Планы развития
Идея определять свойства кода по именам переменных мне нравится, поэтому буду пробовать развивать её как в рамках smart imports, так и в рамках других проектов.
Касательно smart imports, планирую:
- Добавлять поддержку новых версий Python.
- Исследовать возможность опереться на текущие наработки сообщества по аннотации кода типами.
- Исследовать возможность сделать ленивые импорты.
- Реализовать утилиты для автоматической генерации конфига по исходникам и рефакторингу исходников на использование smart_imports.
- Переписать часть кода на C, чтобы ускорить работу с AST.
- Развивать интеграцию с линтерами и IDE, если у тех будут возникать проблемы с анализом кода без явных импортов.
Кроме того, мне интересно ваше мнение по поводу дефолтного поведения библиотеки и правил импорта.
Спасибо что осилили эту простыню текста :-D
Автор: Елецкий Алексей