Обратная совместимость Python-пакета: цена импорта в цифрах
Пакет с ленивыми импортами ради совместимости грузился 380 мс на старте. Разбираем, где утекает время при поддержке старого API и как вернуть 40 мс без слома пользовательского кода.

Библиотека для парсинга, версия 3.x, стартовала 380 мс только на import — до единого вызова. Причина не в логике, а в грузе обратной совместимости: старые пути импорта, алиасы удалённых функций, проверки версий и отложенные предупреждения. Пользователи на CLI-инструментах жаловались, что обёртка вокруг пакета висит полсекунды на пустом запуске. Ниже — где именно уходит время при поддержке старого API и как мы сократили импорт до 42 мс, не удалив ни одного публичного имени.
Материал про Python 3.11 и 3.12: именно на них измерялось. Поведение importlib и стоимость __getattr__ на уровне модуля между этими версиями стабильны; на 3.13 цифры могут отличаться, проверяйте у себя.
Что мерить и на чём
Стоимость старта пакета — это не абстракция, а сумма конкретных вещей: чтение и компиляция байткода, выполнение кода модулей верхнего уровня, транзитивные импорты, регистрация предупреждений. Мерить нужно холодный импорт в свежем процессе, а не в REPL, где часть модулей уже в sys.modules.
Базовый инструмент — встроенный профилировщик импортов, он показывает дерево с временем на каждый модуль:
# Python 3.11+, вывод в stderr в микросекундах
python -X importtime -c "import mypackage" 2> importtime.logФормат строки: import time: self [us] | cumulative | imported package. Столбец self — время самого модуля без вложенных, cumulative — с ними. Сортировать имеет смысл по self: именно там прячется код, который выполняется прямо на импорте.
Для повторяемого замера общего времени — отдельный процесс на каждый прогон, иначе кеш байткода и sys.modules исказят картину:
# bash: 20 холодных запусков, каждый в своём процессе
for i in $(seq 20); do
python -c "import time; t=time.perf_counter(); import mypackage; print((time.perf_counter()-t)*1000)"
doneУсловия замеров ниже: ноутбук с CPU 8 ядер, SSD, Python 3.12.3, пакет из 140 модулей, замер — медиана из 20 холодных прогонов после прогрева файлового кеша (первый запуск отбрасывался).
Дерево importtime до оптимизации
Профиль показал три очага. Во-первых, модуль __init__.py импортировал всё публичное API целиком — 90 модулей — чтобы старые пути вроде from mypackage import LegacyParser продолжали работать. Во-вторых, на старте регистрировались DeprecationWarning для десятка переименованных функций. В-третьих, тянулась тяжёлая зависимость (валидатор схем) только ради одной устаревшей функции, которой почти никто не пользовался.
| Очаг | self-время, мс | Что делает |
|---|---|---|
Транзитивный импорт всего API в __init__ | 210 | Грузит 90 модулей ради удобных путей |
| Импорт валидатора схем | 95 | Нужен одной legacy-функции |
| Регистрация deprecation-алиасов | 18 | Оборачивает функции в предупреждения |
| Остальное (реально нужное на старте) | 42 | Ядро парсера |
Ленивый импорт через module __getattr__
Ключевой приём совместимости без цены на старте — __getattr__ на уровне модуля из PEP 562 (доступен с Python 3.7). Он вызывается, когда атрибут не найден обычным способом, и позволяет подгружать подмодуль только при первом обращении.
Было — жадный импорт в __init__.py:
# __init__.py — старый вариант, всё грузится на import
from mypackage.parser import Parser
from mypackage.legacy import LegacyParser # тянет 90 модулей транзитивно
from mypackage.validators import SchemaValidator # +95 мс
__all__ = ["Parser", "LegacyParser", "SchemaValidator"]Стало — имя разрешается при первом доступе:
# __init__.py — Python 3.7+, ленивое разрешение имён
import importlib
from mypackage.parser import Parser # ядро нужно сразу — грузим явно
# карта: публичное имя -> (модуль, атрибут)
_LAZY = {
"LegacyParser": ("mypackage.legacy", "LegacyParser"),
"SchemaValidator": ("mypackage.validators", "SchemaValidator"),
}
__all__ = ["Parser", "LegacyParser", "SchemaValidator"]
def __getattr__(name):
# вызывается только если name не найден напрямую
target = _LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr = target
module = importlib.import_module(module_name)
value = getattr(module, attr)
globals()[name] = value # кешируем, чтобы __getattr__ не вызывался повторно
return value
def __dir__():
# чтобы dir(mypackage) и автодополнение показывали ленивые имена
return sorted(list(globals()) + list(_LAZY))Строка globals()[name] = value важна: без неё каждое обращение к LegacyParser снова уходило бы в __getattr__. С ней второй доступ — обычный поиск атрибута.
Обратная совместимость — это не бесплатное обещание. Пока имя импортируется жадно, за него платит каждый пользователь при каждом старте, даже тот, кто этим именем не пользуется.
Deprecation-предупреждения без нагрузки на старт
Предупреждения о переименованных функциях тоже уместно перенести в момент обращения, а не выдавать на импорте. Само предупреждение выпускается внутри ленивого геттера:
import warnings
import importlib
_DEPRECATED = {
"parse_xml": ("mypackage.parser", "parse", "parse_xml() переименована в parse()"),
}
def __getattr__(name):
dep = _DEPRECATED.get(name)
if dep is not None:
module_name, attr, message = dep
warnings.warn(message, DeprecationWarning, stacklevel=2)
module = importlib.import_module(module_name)
return getattr(module, attr)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")Параметр stacklevel=2 заставляет предупреждение указывать на строку пользователя, а не на строку внутри пакета — иначе разработчик увидит бесполезное «где-то в mypackage».
Цифры до и после
Замер тот же: медиана из 20 холодных прогонов, Python 3.12.3.
| Сценарий | Импорт, мс |
|---|---|
| Жадный импорт всего API (было) | 380 |
Ленивый __getattr__, валидатор вынесен | 42 |
Первое обращение к LegacyParser (разово) | +63 |
Итог: старт ускорился в девять раз для типичного пользователя, который трогает только Parser. Тот, кому нужен LegacyParser, платит 63 мс один раз за процесс — при первом обращении. Публичный API не изменился: все три пути импорта работают как раньше.
Где подход ломается
- Статические анализаторы теряют имена. mypy и IDE не видят символы, разрешаемые через
__getattr__. Решение —if TYPE_CHECKING:блок с явными импортами: он выполняется только при проверке типов, не в рантайме. from mypackage import *может не подхватить ленивые имена. Звёздочный импорт опирается на__all__, но вычисляет значения через обычный доступ к атрибутам — ленивый геттер сработает, однако проверьте на своей версии, что все имена из__all__реально резолвятся.- Циклические импорты внутри ленивого модуля. Если
legacyимпортирует__init__обратно, отложенная загрузка может вскрыть цикл, который жадный импорт маскировал порядком строк. - Побочные эффекты на импорте. Если старый код рассчитывал, что
import mypackageрегистрирует что-то (плагин, обработчик), ленивость это сломает — регистрацию нужно оставить в__init__явно.
Блок для проверки типов
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# выполняется только mypy/IDE, в рантайме пропускается
from mypackage.legacy import LegacyParser
from mypackage.validators import SchemaValidatorЧто делать дальше
Прогоните python -X importtime на своём пакете и отсортируйте по столбцу self — очаги видно сразу. Вынесите в ленивый __getattr__ всё, что не нужно на первой секунде работы, но оставьте ядро жадным: экономить на модуле, который всё равно грузится немедленно, смысла нет. После правок повторите замер в 20 холодных прогонах и зафиксируйте цифру в CI отдельным тестом на время импорта — он ловит регрессии, когда кто-то снова добавит тяжёлый импорт в __init__. Инвалидация и версионирование самого API — отдельная тема, здесь мы трогали только цену старта.
Таможенный брокер (ВЭД): Коды ТН ВЭД, маркировка «Честный ЗНАК», сертификация.
Спросить за 20 ₽Источники: PEP 562 — Module __getattr__ and __dir__, Python docs: -X importtime option
Частые вопросы
А если у меня Python 3.6 или старше?
Ленивый импорт не сломает существующий пользовательский код?
Почему первое обращение к ленивому имени всё равно медленное?
Не сработало: mypy ругается, что имя не определено.
Как поймать регрессию, когда кто-то снова замедлит импорт?
Стоит ли делать ленивым вообще всё, включая ядро?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.