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

    До После
    import math
    import os.path
    
    import requests
    
    # 100500 other imports
    
    print(math.pi)
    print(os.path.join('my', 'path'))
    print(requests.get)
    import smart_imports
    
    smart_imports.all()
    
    print(math.pi)
    print(os_path.join('my', 'path'))
    print(requests.get)
    
    Так получилось, что аж с 2012 года я разрабатываю open source браузерку, являясь единственным программистом. На Python само собой. Браузерка — штука не самая простая, сейчас в основной части проекта больше 1000 модулей и более 120 000 строк кода на Python. В сумме же с проектами-спутниками будет раза в полтора больше.

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

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

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

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

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

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


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

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

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

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

    Дотошливый читатель заметит, что 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 не сказывается на показателях работы программы, но увеличивает время её запуска.

    Из-за повторного построения 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)

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


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

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

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

    Профит


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

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

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

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


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

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

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

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

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

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Попробуете smart_imports в своём проекте?

    Поддержать автора
    Поделиться публикацией

    Комментарии 31

      +10
      Немного противоречит PEP 20
      Explicit is better than implicit.
        0
        Есть такое.

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

        Плюс, для быстрого написания одноразового кода тоже удобно.
        +2

        А как насчёт работы линтеров? flake8, pylint? Уживаются ли они с библиотекой?

          0
          Для pylint я отключил проверку ошибки E0602 — Undefined variable. Поскольку такие ошибки теперь ловятся при запуске (smart imports пишут для каких переменных не нашли импорты). В остальном вроде работает.

          Определённые проблемы с линтерами и автодополнением в IDE я ожидаю. Буду их решать по мере поступления сообщений об ошибках.
            +5

            Подозреваю, в mypy теперь целиком и полностью сломалась проверка типов, связанных с импортируемыми модулями (да и в pylint наверно тоже)

          0
          Коллега предложил улучшение, если либа не найдена идти в pypy и искать либу там, после чего устанавливать её.
            +2
            Я думаю это некоторый перебор: потенциальная дырка в безопасности. Да и можно загрязнить левое окружение, если случайно не оттуда запустились.

            С другой стороны, такое правило для импорта написать не сложно в виде отдельного плагина.
            +5

            Основную проблему уже обозначили выше — такой подход ломает любой линтинг и автокомплит, что, собственно, безумно плохо.

              +1
              Вообще, импорты добавляет IDE автоматически, поэтому никогда с ними долго не возился. Ну и когда импорты в открытом виде в файле, это наглядно, сразу видишь, что он использует.
              Как в данном случае либа будет сочетаться с IDE?
                0
                Думаю, это будет зависеть от IDE и возможности пилить под неё плагины.
                +9

                Очевидное всё-таки лучше неочевидного. Если разработчик не знает/не хочет знать, где у него что импортировано и использует некую магию "сделай мне всё красиво, некогда объяснять", это скорее bad practice. Как pet-проект для себя — возможно, но в production такого следует избегать.

                  +5

                  А как решается проблема "откуда это взялось?" при чтении исходного кода?

                    –2
                    Фиксированные правила импорта и продуманная структура проекта оставляют не много места для таких вопросов. Кроме того, всегда можно конфиг посмотреть.
                      +5

                      Нет, это вы отвечаете на вопрос "как этот модуль тут появился". А у меня другой вопрос — "откуда это взялось"?


                      Если у меня в коде написано queue.Empty — это локальная переменная которую кто-то где-то инциализировал или это модуль? А если у меня есть переменная chunk и я забыл её проинциализировать, а вы мне любезно туда chunk модуль подсунули? За что?

                        –2
                        Нет, это вы отвечаете на вопрос «как этот модуль тут появился». А у меня другой вопрос — «откуда это взялось»?

                        Приведите пример в котором ответ на ваш вопрос не даётся просмотром импортов. Пока я не понимаю что вы хотите сказать.

                        Если у меня в коде написано queue.Empty — это локальная переменная которую кто-то где-то инициализировал или это модуль? А если у меня есть переменная chunk и я забыл её проинициализировать, а вы мне любезно туда chunk модуль подсунули? За что?

                        Для этого в проекте должно совпасть несколько довольно критичных ошибок:

                        — Должно случиться пересечение имён модулей и переменных.
                        — Функциональность не должна быть покрыта ни одним тестом (либо тесты не запускаются).
                        — Должен быть написан кривой код.

                        Если такое случается чаще раза в год, то на проекте проблемы совсем другого рода и, действительно, играться с метапрограммированием команде не стоит.

                        Сейчас в Python идёт движение за то, чтобы модуль выглядел как обычный объект. Добавляются разные технические методы для эмулирования этого. Например есть PEP-0562 То есть с точки зрения кода должно быть без разницы что в переменной находится (модуль, функция, обычный объект), пока эта сущность предоставляет нужный интерфейс.

                        Плюс, разносить объявление переменной и её использование на несколько экранов (а именно в этом случае нельзя сказать что в ней) — моветон. Не надо так делать.
                    +9

                    Как одной строчкой сломать/отключить работу в любой IDE autocomplete, linter'ов, функций рефакторинга, статических анализаторов типов и т.д. Список можно продолжать. Не говоря о невозможности использовать условные импорты.

                      –1
                      Почему условные импорты невозможно использовать?
                        0

                        Из доступных правил не получится сделать так:


                        try:
                            import loads from ujson as json_loads
                        except ImportError:
                            import loads from json as json_loads
                          0
                          Не обязательно всё пихать в правила. Нужен условный импорт — делаем условный импорт.

                          import smart_imports
                          
                          smart_imports.all()
                          
                          try:
                              from ujson import loads as json_loads
                          except ImportError:
                              from json import loads as json_loads
                          
                          json_data = '{"x": 1}'
                          
                          
                          parsed_data = json_loads(json_data)
                          
                          parsed_data['y'] = math.pi
                          
                          print(parsed_data)
                          # {'x': 1, 'y': 3.141592653589793}
                          


                          А если очень надо, то и правило написать не сложно. Только оно не надо скорее всего, поскольку усовный импорт должен только в одном месте делаться.
                      +2
                      Напоминает makeheaders для Си, только там всё же генерировался implicit header.
                        0
                        мне кажется, модулей слишком много. вот смотрю один из своих проектов на delphi — примерно 1.5. млн строк и 550 модулей.
                        я как-то пробовал отлаживать плюсовый код с примерно тысячей модулей. после открытого примерно 30-го файла по колл-стэку я окончательно потерялся.
                          0
                          То есть по ~2727 строк на файл (в среднем) учитывая разброс, должны быть и по 10 000 строк исходники. По-моему это немного перебор :-D

                          Касательно моего проекта. В вебе есть есть определённые соглашения по организации кода. Например, фреймворк Django рекомендует отдельно описывать схему базы, структуру url и прочее для каждого компонента. Это приводит к появлению файлов с небольшим количеством кода, но засчёт правильного разделения кода по модулям это в итоге удобно.
                          0
                          Полагаю, такая штука имела бы смысл, если сделать её полностью compile-time, с поддержкой от IDE. Я бы вообще просто сделал плагин к vscode, который делает то же самое, и обучает остальные линтеры считать импорты «совершёнными», и всё, можно работать
                            +4
                            Как альтернатива — сделать один модуль который будет содержать все нужные импорты, и использовать:
                            from all_libs import *
                            Без магии и инструментарий не поломается.
                              +1
                              Для малых проектов это прокатило бы. Для больших будет скорее геморойно:

                              — Циклические зависимости вылезут во всей красе.
                              — В случае компонентов, организованных по одному шаблону, нельзя будет обратиться к соседнему модулю по имени (так как оно пересечётся с таким же именем в другом компоненте). То есть вопрос импортов полностью не закроется.
                              — Время запуска будет максимльным из возможного, так как вообще всё придётся импортировать.
                                –2
                                1. Циклические импорты можно решить через инициализацию (т.е. «проблемный» код переместить в ф-ю из глобальной части, и запускать когда все уже импортированно)
                                А как у вас оно решается, когда нужны 2 модуля которые цыклический друг от друга?

                                2. Такие импорты делать класический или по полному пути компонента, либо аналогично сделать локальный модуль импорта.

                                3. Если проект стартует целиком и эти модули импортируются в любом случае (монолит), то разницы не будет.
                                А если нужна возможность запуска кусками, то можно сделать «режимы запуска» где будет импортироваться не все.

                                Ещё как вариант не импортировать глобально, а как один модуль, и использовать, например, как libs.math.sqrt, тогда можно вообще сделать ленивую подгрузку модулей.

                                Так же можно комбинировать подходы, и ложить в общий импорт только то, что действительно используется часто, тогда кол-во импортов скоратится до минимума.

                                Кроме того есть и другие архитектурные решения, где минимум импортов в больших проектах.
                                +2
                                Насколько я понимаю используя и этот подход в том числе вы понимажаете понятность кода для самого себя в том числе. Ну и куча ненужного в коде лежит в итоге. Думаю
                                from {что-то} import *
                                использовать вовсе плохая практика, хоть питон и позволяет, да.
                                +3
                                Я думаю, что избыточное количество импортов указывает на то, что у проекта высокая связность классов, а у отдельных скриптов куча задач. Думаю правильное разделение, а не убер-библиотека, вот что нужно. А так это похоже на костыль, который скрывает основную проблему.
                                  +1

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

                                    +1
                                    сначала сделали динамический язык
                                    потом добавили «типизацию»
                                    теперь сделаем неявный импорт
                                    потом что добавим?
                                      0

                                      PyCharm же всё автоматически добавляет, зачем ещё какую-то магию городить?

                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                      Самое читаемое