Python lazy import

Оптимизация загрузки скриптов.

2022.11.10

Ленивые импорты

Для использования сторонних пакетов — и своих тоже — в python’е используется конструкция import:

import everything from the_universe
import * from stars  # don't do this, though

Все importы, по умолчанию, выполняются во время загрузки скриптов. Это не всегда хорошо: представим себе CLI-утилиту с двумя командами:

$ ./utility.py --help
./utility.py usage:

* --help        Shows this message
* hard_work     Does all the work

$ ./utility.py hard_work
Importing the universe...
Looking for stars*...
Done!

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

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

$ PYTHONPROFILEIMPORTTIME=1 ./utility.py --help
import time: self [us] | cumulative | imported package
import time:      1000 |       1000 | the_universe
import time:      2000 |       2000 | stars
...

Для чтения таких файликов рекомендую использовать tuna, визуализатор профайлов:

$ PYTHONPROFILEIMPORTTIME=1 ./utility.py --help 2> import.log
$ tuna import.log

Что делать с такими импортами, которые нужны не всегда? Использовать ленивые импорты! На эту тему уже расписан PEP 690, если хотите действительно полезной информации, читайте его.

Ленивые импорты можно (и нужно) поддерживать нативно, на уровне инерпретатора, чтобы была возможность учитывать всю необходимую семантику. Такое уже умеет cinder, экспериментальный форк python’а от facebook. В нём есть и другие интересные оптимизации, см. readme.md#whats-here.

Заготовка для поддержки ленивых импортов есть в importlib:

import importlib.util
import sys
def lazy_import(name):
    spec = importlib.util.find_spec(name)
    loader = importlib.util.LazyLoader(spec.loader)
    spec.loader = loader
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    loader.exec_module(module)
    return module

lazy_typing = lazy_import("typing")
#lazy_typing is a real module object,
#but it is not loaded in memory yet.
lazy_typing.TYPE_CHECKING
False

Попробуем переписать наши импорты на ленивые:

from lazy_import import lazy_import

universe = lazy_import("universe")
stars = lazy_import("stars")

Теперь, модули universe и stars будут импортированы только при обращении к их членам:

universe.everything  # Раскручивается importlib, подгружает модуль
stars.sun  # same as above

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

Более “нативно-выглядящая” поддержка может быть реализована с помощью подмены импортера, как сделано в py-demandimport:

import demandimport; demandimport.enable()
# Imports of the following form will be delayed
import a, b.c
import a.b as c
from a import b, c  # a will be loaded immediately, though

Здесь основной минус — это monkeypatching в красивой обёртке, что всегда нехорошо.

Ссылки