Модули и импорты вглубь: sys.modules, финдеры и лоадеры, ловушка циклического импорта
После первого exec import — поиск в sys.modules: модули — синглтоны на весь процесс. Финдеры на sys.meta_path находят код, лоадеры исполняют его однажды; каталог скрипта затеняет stdlib; циклический from-import ловит полусобранный модуль; python -m даёт пакетный контекст.
Платёжный сервис стартовал 14 секунд. Не до приёма трафика — до первой строки main(). Kubernetes убивал поды посреди раскатки: readiness-проба давала им десять. python -X importtime выписал чек: одна безобидная цепочка — app.handlers импортировал app.analytics, тот импортировал pandas, к которому кто-то добавил torch ради одной сигнатуры функции. Аннотация принимала pandas.DataFrame и возвращала torch.Tensor, функцию вызывал батч-джоб раз в сутки — и при этом каждый веб-воркер платил 12 секунд импорта, потому что импорты уровня модуля выполняются во время импорта, безусловно, в каждом процессе, который коснулся цепочки. Фикс занял четыре строки: убрать оба импорта под if TYPE_CHECKING: и импортировать лениво внутри единственной функции, которая гоняет модель. Старт упал до 1,3 секунды. Никто не писал медленный код — код написали не в том месте, а система импорта исполнила его ровно так, как спроектирована.
import — это поиск в словаре. После первого раза
К концу этого раздела вы будете точно знать, почему перенос двух строк превратил 14 секунд в 1,3 — и как диагностировать ту же картину в собственном коде.
import json делает что-то дорогое максимум один раз на процесс. В первый раз Python находит модуль, выполняет весь его код верхнего уровня в свежем пространстве имён и кладёт получившийся объект модуля в sys.modules — обычный словарь с именами модулей в ключах. Каждый последующий import json, в любой точке процесса, — попадание в словарь: наносекунды, без I/O, без повторного выполнения. Два следствия правят повседневностью. Первое: код верхнего уровня модуля — это однократная инициализация процесса; именно поэтому import pandas из Хука стоил 12 секунд каждому воркеру — каждый свежий процесс платит цену первого импорта за всю транзитивную цепочку. Второе: модули — синглтоны на весь процесс: settings.DEBUG = True мутирует тот единственный общий объект, который видят все импортёры; на этом стоит весь манкипатчинг — и тот тест, который изменил атрибут модуля и тихо поменял поведение в чужом наборе тестов. Удаление записи из sys.modules заставит модуль выполниться заново при следующем импорте; этим пользуются перезагрузчики плагинов pytest — и этого почти никогда не стоит делать руками.
Машинерия: финдеры, лоадеры и анатомия sys.path
При промахе кеша Python обходит sys.meta_path — список финдеров. Стоковый CPython ставит три: для встроенных модулей, для замороженных и PathFinder, который идёт по sys.path каталог за каталогом. Финдер, признавший имя, возвращает ModuleSpec (объект-описатель модуля: путь к файлу, загрузчик и имя пакета) с лоадером; система импорта создаёт пустой объект модуля, регистрирует его в sys.modules до выполнения чего-либо и просит лоадер исполнить код модуля в это пространство имён. Запомните порядок — сначала регистрация, потом выполнение: это единственный факт, решающий, какие циклические импорты выживают.
У самого sys.path заряженный ствол под нулевым индексом: при python script.py нулевая запись — каталог скрипта (при -m или в REPL — текущий каталог). Локальные файлы обгоняют стандартную библиотеку. Классика: помощник random.py в рабочем каталоге, и тремя слоями глубже какая-то библиотека делает import random и получает ваш файл. Ошибка приходит далеко от причины — AttributeError: module 'random' has no attribute 'choice' в недрах чужого трейсбека; а если ваш random.py сам импортирует random, получите циклический вариант. Python 3.13 наконец печатает подсказку переименовать затеняющий файл; на всём, что старше, диагностический ход — import random; print(random.__file__). Дисциплина, закрывающая класс ошибок: абсолютные импорты по умолчанию, относительные (from . import db) — только для внутрипакетных ссылок, и никогда не называть модуль именем чего-либо из стандартной библиотеки.
Коллега добавил tools/random.py для тестовых фикстур и запускает скрипты из tools/. Набор тестов падает с AttributeError: module 'random' has no attribute 'choice' — внутри requests, который никто не трогал. Что случилось?
Пакеты: init.py выполняется, и оговорка про namespace-пакеты
import pkg.sub — это два импорта: сначала Python импортирует pkg — а значит, выполняется pkg/__init__.py, — потом pkg.sub. Поэтому тяжёлый __init__.py — налог на весь пакет: популярный паттерн удобных реэкспортов (from .client import Client и ещё десять строк) заставляет каждого потребителя, которому нужен один маленький сабмодуль, заплатить за все. Цепочка из Хука на 12 секунд имела ровно эту форму — __init__.py, агрегирующий сабмодули, которые агрегировали pandas. Держите инициализаторы пакетов на уровне реэкспорта дешёвых имён, а тяжёлую обвязку уносите в модули, которым она нужна.
Уберите __init__.py — получите namespace-пакет: импортируется, кода инициализации нет, и — собственно фича — несколько каталогов на sys.path могут вносить части, сливающиеся в один логический пакет (company.auth из одного дистрибутива, company.billing из другого). Честные издержки: вовсе нет хука инициализации уровня пакета, чуть более медленный поиск (нужно опросить каждую запись пути) и аварийный режим — забытый __init__.py молча создаёт namespace-пакет вместо ошибки, маскируя огрехи раскладки до самого этапа упаковки. Если вы не делите один неймспейс между дистрибутивами сознательно — держите явные __init__.py.
Циклические импорты: почему умирает именно from-import
Когда видите ImportError: cannot import name 'X' from partially initialized module, первый импульс — перетасовать порядок импортов. Но правильный вопрос другой: какой вид импорта использует ваш код и когда он требует имя. Понимание этого различия ведёт к нужному фиксу, а не к везению.
Модуль a импортирует b; на середине выполнения b импортирует a обратно. Поскольку a зарегистрирован в sys.modules до начала выполнения своего тела, цикл не уходит в рекурсию — b просто получает полусобранный объект модуля: всё, что a успел связать выше своей строки import b, существует; всё ниже — ещё нет.
# a.py # b.py
config = {"region": "eu"} import a # получает полусобранный a: ок
import b # from a import helper ← ImportError!
def helper(): ... def use():
return a.helper() # в момент вызова a готовimport a внутри b выживает, потому что лишь связывает объект модуля и откладывает доступ к атрибутам до момента вызова — к тому времени a довыполнился. from a import helper требует атрибут прямо сейчас, посреди выполнения, и поднимает ImportError: cannot import name 'helper' from partially initialized module 'a' (most likely due to a circular import) — CPython буквально называет болезнь по имени. Фиксы, по ранжиру: реструктурировать — вынести общий кусок в третий модуль, который импортируют оба, убив цикл (лучший вариант, обычно вскрывает реальный изъян слоёв); поздний импорт — перенести импорт внутрь функции, отложив разрешение за пределы инициализации (прагматично, прячет зависимость); импортировать модуль, а не имя — import a плюс a.helper() в момент вызова (дешевле всего, цикл остаётся на месте).
Вместе эти три варианта покрывают все реальные ситуации с циклическими импортами: без реструктуризации вы лечите симптом, оставляя архитектурный изъян; без знания позднего импорта можно удалить нужную функциональность, пытаясь убрать цикл.
В a.py наверху from b import render; в b.py наверху from a import config. Импорты взрываются. Замена в b.py на простой import a с чтением a.config внутри функций всё чинит. Почему это работает?
Сайд-эффекты времени импорта, main и флаг -m
Всё выше складывается в одно правило: уровень модуля — для определений, а не действий. Модульный db = psycopg.connect(...) выполняется при импорте — в каждом процессе, транзитивно коснувшемся модуля, включая тест-раннер, которому нужна была одна константа, и в момент, когда конфиг может быть не загружен, а циклов событий не существует. Жизненный цикл для такой работы — явный стартовый хук, паттерн lifespan из юнита про веб-сервисы, а не время импорта. Тест на запах: импорт любого вашего модуля должен быть свободен от I/O, а python -X importtime -c "import yourpkg" — профилировщик, который это докажет.
Последний кусок: как файл становится программой. python pkg/tool.py выполняет файл как __main__ без пакетного контекста — __package__ пуст, поэтому from . import db поднимает ImportError: attempted relative import with no known parent package, а sys.path[0] становится pkg/, заново взводя ствол затенения. python -m pkg.tool, напротив, честно импортирует pkg (выполнив его __init__.py), исполняет tool как __main__ с пакетным контекстом и кладёт в sys.path[0] текущий каталог, а не каталог скрипта. Скриптам, живущим внутри пакетов, нужен -m; поэтому же console-script точки входа (следующий урок) генерируют шимы, которые импортируют ваш модуль, а не исполняют файл.
- 01Пройдите полный путь import app.handlers на холодном процессе — и назовите деталь порядка, решающую, выживет ли циклический импорт.
- 02Почему один тайп-хинт стоил 14 секунд старта, и каков дисциплинированный фикс — включая роль -m в этой картине?
Оператор import — операция о двух скоростях: в первый раз работает машинерия — финдеры на sys.meta_path находят код, лоадер один раз исполняет тело модуля в свежее пространство имён; каждый следующий раз — попадание в словарь sys.modules. Этот кеш делает модули синглтонами на весь процесс (мутация видна всем импортёрам — механизм манкипатчинга), а код верхнего уровня — однократной инициализацией процесса; поэтому один импорт pandas+torch ради тайп-хинта стоил сервису из Хука 14 секунд в каждом воркере, и поэтому фиксом стали TYPE_CHECKING и ленивый импорт, а не более быстрый код. Порядок «регистрация до exec» — модуль попадает в sys.modules пустым, тело выполняется вторым — решает судьбу циклических импортов: import x выживает, откладывая доступ к атрибутам до вызова, from x import name умирает на частично инициализированном модуле, а фиксы ранжируются: реструктуризация выше позднего импорта выше импорта-модуля-вместо-имени. sys.path[0] — каталог скрипта, поэтому локальный random.py затеняет stdlib для всего процесса; пакеты выполняют __init__.py при импорте, так что тяжёлые инициализаторы — налог на каждого потребителя; пропавший __init__.py молча создаёт namespace-пакет — фича, лишь когда вы сознательно делите неймспейс между дистрибутивами. А файл внутри пакета становится программой через python -m pkg.tool, сохраняющий пакетный контекст, — прямой запуск файла его срывает и заново взводит все ловушки выше. Теперь, когда встретите десятисекундный старт, затенённый атрибут stdlib в недрах чужого трейсбека или ImportError о частично инициализированном модуле, вы точно знаете, на какой слой смотреть первым — и к какому из трёх фиксов тянуться.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.