Импорт насквозь

import не подключает файл, а исполняет его — ровно один раз за процесс. Отсюда общее состояние модулей, циклические импорты, «где патчить mock» и половина странностей, которые видит QA.

L1 · 9 минL2 · 10 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
# pkg/__init__.py содержит print("исполняется")
import pkg
import pkg
import pkg
исполняется
Три импорта, одна строка вывода.
Фрагмент B
# pkg/a.py:  from pkg.b import helper
# pkg/b.py:  from pkg.a import go
import pkg.a
ImportError: cannot import name 'go' from partially
initialized module 'pkg.a' (most likely due to a
circular import)
Ключевое слово — partially initialized.
Фрагмент C
# src.py:      def now(): return "настоящее время"
# consumer.py: from src import now
#              def report(): return f"отчёт: {now()}"

with patch("src.now", return_value="ФЕЙК"):
    print(consumer.report())

with patch("consumer.now", return_value="ФЕЙК"):
    print(consumer.report())
отчёт: настоящее время
отчёт: ФЕЙК
Патчим ту же функцию двумя путями. Работает только один.
Фрагмент D
import pkg
pkg.VERSION = "изменено"

import pkg as pkg2
print(pkg2.VERSION)
изменено
Другое имя, другой импорт — то же состояние.

2Механизм

Модуль — объект. Его пространство имён — обычный словарь, тот самый из урока 05.

import исполняет файл сверху донизу в этом словаре — как тело функции или класса.

Результат кэшируется в sys.modules по имени. Повторный импорт достаёт готовый объект и не исполняет ничего.

Следствие 1 · модуль исполняется один раз, состояние общее

Фрагменты A и D. Первый импорт исполняет файл и кладёт объект в sys.modules; все последующие возвращают его же. Значит любое изменение атрибута модуля видно везде, где его импортировали, — независимо от того, под каким именем.

Отсюда две вещи сразу. Хорошая: модуль — готовый синглтон, и отдельный паттерн для него не нужен. Плохая: всё на верхнем уровне модуля живёт весь процесс — кэши, соединения, счётчики, изменённые атрибуты классов. Это и есть источник «тест проходит один и падает в наборе»: состояние протекает между тестами, а порядок лишь проявляет проблему.

Следствие 2 · from x import y связывает имя, а не ссылается на модуль

Здесь самая частая мёртвая зона, и фрагмент C её вскрывает. from src import now делает буквально следующее: импортирует src, достаёт из его словаря now и кладёт под тем же именем в словарь модуля-потребителя. Получаются две независимые записи, указывающие на один объект.

Подмена src.now переписывает запись в словаре src. Запись в словаре consumer при этом не меняется — она по-прежнему указывает на старую функцию. Поэтому патчить надо по месту использования: patch("consumer.now").

Это не свойство библиотеки моков, а прямое следствие того, что имя — привязка (урок 01), а неймспейс модуля — словарь. И то же объясняет, почему import src с обращением src.now() патчится и так и так: обращение через модуль читает словарь src каждый раз.

Следствие 3 · циклический импорт видит полуготовый модуль

Фрагмент B. Чтобы защититься от бесконечной рекурсии, Python кладёт объект модуля в sys.modules до исполнения его тела. Значит при цикле второй импорт получает объект, в котором выполнена только часть строк.

Дальше зависит от формы записи. from pkg.a import go требует имя прямо сейчас — и падает, если до его определения дело ещё не дошло. А import pkg.a с обращением pkg.a.go() внутри функции сработает: к моменту вызова модуль уже дособран.

Отсюда три способа починки, и все выводятся из механизма: перенести импорт внутрь функции, импортировать модуль целиком вместо имени из него, или выделить общую часть в третий модуль, разорвав цикл.

Следствие 4 · импорт может делать что угодно

Раз это исполнение обычного кода, при импорте может произойти всё: открыться соединение, прочитаться файл, зарегистрироваться обработчик, уйти запрос в сеть. Язык этого не запрещает и не может запретить.

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

3Границы модели

Где сказанное перестаёт держать

sys.modules можно менять руками — и на этом стоят подмены в тестах и ленивые загрузчики. Но удаление записи не «выгружает» модуль: старый объект жив, пока на него ссылаются, и второй импорт создаст второй модуль с отдельными классами. Отсюда загадочное isinstance, возвращающее False для объекта «того же» класса.

importlib.reload не перезагружает мир. Он повторно исполняет тело в том же словаре, но уже созданные объекты и классы остаются старыми, а другие модули продолжают держать привязки к прежним функциям — прямо по следствию 2.

Путь поиска — не только sys.path. Механизм расширяемый: sys.meta_path и sys.path_hooks позволяют импортировать откуда угодно — из архива, из сети, из базы. На этом стоят zip-приложения и системы плагинов.

4Корень

Корень R7: импорт — это исполнение. Решение принято в самом начале и вытекает из общей модели: раз def и class исполняются, а модуль — просто файл с кодом, то и модуль исполняется. Никакой отдельной фазы «подключения» в языке нет.

Отсюда и то, что import — обычная инструкция, а не директива препроцессора: его можно написать внутри функции, под if, в цикле. Ровно этим и пользуются для разрыва циклов и ленивой загрузки тяжёлых зависимостей.

Цена — отсутствие статической картины программы. Компилятор не знает, что откуда придёт; инструменты вынуждены угадывать; ошибка в импорте обнаруживается только при запуске. Взамен получена расширяемость: подменить механизм импорта — обычная задача, а не хак.

5Аналогия

Ближайшая точная аналогия — статическая инициализация в C++. Глобальный объект с конструктором выполняет код до main, ровно один раз, и порядок между единицами трансляции не определён — знаменитая «static initialization order fiasco». Модуль Python ведёт себя так же: код верхнего уровня — это конструктор, который отработает один раз при первом обращении.

И проблема та же по сути: если один модуль на верхнем уровне зависит от того, что уже отработал другой, порядок начинает иметь значение — а определяется он тем, кто кого импортировал первым. Лечение тоже совпадает по духу: в C++ применяют ленивую инициализацию через функцию с локальным статиком, в Python — переносят работу из тела модуля в функцию, вызываемую явно.

Разница в одном и в пользу Python: у него порядок хотя бы детерминирован и виден в коде, а не отдан на откуп компоновщику.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему циклический импорт иногда работает, а иногда падает?

Потому что модуль попадает в sys.modules до исполнения своего тела — и вопрос только в том, успело ли выполниться нужное имя.

Когда a импортирует b, а b обратно a, второй импорт находит a в кеше и не запускает его заново — иначе была бы бесконечная рекурсия. Но тело a в этот момент выполнено лишь частично.

# a.py
X = 1
import b
Y = 2

# b.py
import a
print(a.X)      # → 1     эта строка уже выполнилась
print(a.Y)      # → AttributeError: partially initialized module

Отсюда всё наблюдаемое поведение: import a внутри цикла обычно работает (получаем объект модуля, обращаемся к атрибутам позже), а from a import Y — падает, потому что требует, чтобы Y уже существовал в момент импорта.

Как чинить, в порядке предпочтения. Первое — вынести общее в третий модуль: цикл обычно означает, что два модуля делят сущность, у которой нет своего места. Второе — импортировать внутри функции, а не на верхнем уровне: к моменту вызова оба модуля готовы. Третье — if TYPE_CHECKING, если импорт нужен только для аннотаций (урок 18).

Чего не делать: переставлять импорты местами до тех пор, пока не заработает. Это чинит симптом, и следующий человек, добавивший строку, сломает всё обратно.

Почему mock надо патчить по месту использования, а не по месту определения?

Потому что from x import y создаёт отдельную привязку в импортирующем модуле, и подмена в исходном модуле её не касается.

# utils.py
def now(): return "настоящее время"

# report.py
from utils import now          # своя ссылка на объект функции
def make(): return f"отчёт: {now()}"
from unittest.mock import patch
import report

with patch("utils.now", return_value="ФЕЙК"):
    print(report.make())       # → отчёт: настоящее время   не сработало

with patch("report.now", return_value="ФЕЙК"):
    print(report.make())       # → отчёт: ФЕЙК              сработало

Причина прямо из механизма: from utils import now исполняется один раз при импорте report и записывает текущее значение в глобальные report. Дальше это две независимые записи в двух словарях, указывающие на один объект. Меняя одну, вторую не трогаешь.

А import utils + utils.now() ведёт себя иначе: там на каждом вызове читается атрибут модуля, и патч в utils виден.

Правило запоминается одной фразой: «патчь там, где ищут, а не там, где лежит». И именно поэтому mock.patch принимает строку, а не объект — по объекту невозможно понять, в чьём неймспейсе его подменять.

Почему from module import * — плохо?

Потому что он делает невозможным ответ на вопрос «откуда взялось это имя», причём и для человека, и для инструментов.

Конкретные последствия, по убыванию вредности:

  • Тихое затирание. Два таких импорта подряд — и второй молча перекрывает имена первого. Ошибка проявится далеко от места.
  • Инструменты слепнут. Линтер не отличит опечатку от импортированного имени, IDE не найдёт определение, mypy теряет типы.
  • Зависимость становится неявной. Из кода не видно, что именно используется, — значит нельзя ни сузить импорт, ни понять, что сломается при обновлении.
  • Импортируется больше, чем видно. Без __all__ приезжают все публичные имена модуля, включая его собственные импорты.
# mod.py
import os, sys
PUBLIC = 1
_private = 2

# другой файл
from mod import *
print(PUBLIC, os)     # → 1 и модуль os приехал заодно

__all__ в модуле ограничивает список, но полагаться на чужую дисциплину не стоит.

Где приемлемо: в интерактивной сессии, где важна скорость набора; в __init__.py пакета, который сознательно переэкспортирует свой публичный API — и там обязательно с __all__; в конфигурационных модулях вроде settings_local, где перекрытие и есть цель. Во всём остальном коде — явный список имён.

Зачем __init__.py, если пакеты работают и без него?

Работают, но становятся другим типом пакета — с другим поведением, о котором обычно узнают в проде.

С 3.3 (PEP 420) каталог без __init__.py становится namespace package. Он задуман для одной задачи: разложить один логический пакет по нескольким местам на диске, чтобы company.tools и company.web ставились отдельными дистрибутивами.

Отличия, которые кусаются:

  • Namespace-пакет собирается из всех подходящих каталогов на sys.path. Случайно совпавшее имя каталога где-то ещё — и содержимое пакета молча смешивается.
  • Опечатка в имени не даёт ошибки импорта сразу. Пустой namespace-пакет импортируется успешно, а падает уже на обращении к подмодулю.
  • Инструменты ведут себя по-разному. Часть сборщиков и find_packages() по умолчанию их не видит, и в колесо каталог не попадает.
import mypkg
print(mypkg.__path__)
# обычный пакет:   ['/путь/mypkg']
# namespace:       _NamespacePath(['/путь/mypkg', '/другой/путь/mypkg'])

Правило простое: если не строишь распределённый по дистрибутивам пакет — клади __init__.py, пустой. Он стоит ноль байт и делает поведение предсказуемым. Namespace-пакеты — специальный инструмент, а не «современный способ обойтись без файла».

Итог. Модуль — объект, его неймспейс — словарь, а import исполняет файл сверху донизу и кэширует результат в sys.modules. Отсюда: тело модуля выполняется ровно один раз за процесс, поэтому всё на верхнем уровне живёт весь запуск и протекает между тестами. from x import y создаёт отдельную привязку в словаре потребителя, поэтому подменять надо по месту использования, а не по месту определения — это вопрос про связывание имён, а не про библиотеку моков. Объект модуля попадает в кэш до исполнения тела, чтобы оборвать рекурсию, поэтому циклический импорт видит полуготовый модуль и падает на from ... import, но переживает import модуля целиком. И раз это обычное исполнение, при импорте может произойти что угодно — включая секунды работы на старте и сайд-эффекты, чей порядок зависит от того, кто кого импортировал первым.
Дальше — по желанию
контрфактуалC, C++ модули, JavaТекстовая вставка препроцессором против исполнения — и почему у Python нет проблемы двойного включения

C с его #include — противоположный полюс: препроцессор буквально вставляет текст файла в место включения, до всякой компиляции. Отсюда весь набор проблем, которых у Python нет: двойное включение (лечится #pragma once или include guard), порядок включений, влияющий на смысл, и раздувание единиц трансляции. Python решает это иначе — не защитой от повторного включения, а тем, что повторного исполнения просто не происходит: sys.modules и есть встроенный include guard.

Зато у C есть то, чего нет у Python: полная статическая картина. Компоновщик знает все символы, неразрешённая ссылка — ошибка сборки, а не рантайма.

Модули C++20 — попытка получить оба свойства: единица импорта компилируется один раз, интерфейс отделён от реализации, порядок не важен. Это ближе к Python по семантике, но остаётся на этапе компиляции.

Java компилирует каждый класс отдельно и грузит по имени в рантайме — механизм ближе к питоновскому, включая возможность подменить загрузчик. Но import в Java лишь сокращает запись имён, ничего не исполняя.

🐛 багСостояние модуля, протекающее между тестамиТест зелёный по одному и красный в наборе — потому что модуль исполнился один раз, а тестов много

Классика, которую QA видит еженедельно, а причину обычно ищут не там.

# settings.py
FEATURES = load_features()          # исполнится один раз за процесс

# test_a.py
def test_new_flow():
    settings.FEATURES["new_flow"] = True     # включили для своего теста
    assert run() == "new"

# test_b.py
def test_old_flow():
    assert run() == "old"                    # падает, если test_a шёл раньше

Оба теста корректны по отдельности. Но settings импортируется один раз, и словарь FEATURES — один на весь прогон: первый тест его изменил, второй получил изменённый. Порядок тестов проблему не создаёт, а лишь проявляет — отсюда и «падает через раз», и «падает только в CI, где другой порядок».

Ревью пропускает, потому что в диффе видно один тест, а сломался другой, лежащий в другом файле.

@pytest.fixture(autouse=True)
def _restore_features():
    saved = copy.deepcopy(settings.FEATURES)
    yield
    settings.FEATURES.clear()
    settings.FEATURES.update(saved)

Обрати внимание на deepcopy — поверхностной копии тут мало по причине из урока 01, а clear+update вместо присваивания нужны потому, что другие модули держат привязку к тому самому объекту словаря (следствие 2), и подмена его новым до них не дойдёт.

Общее правило: изменяемое состояние на верхнем уровне модуля — это глобальная переменная процесса. Либо не менять его, либо восстанавливать явно.

⚡ приёмНайти, что тормозит стартpython -X importtime показывает дерево импортов с миллисекундами — обычно виновата пара строк

CLI-утилита стартует полторы секунды, и никто не знает почему. Ответ — одна команда.

python -X importtime -c "import myapp" 2>&1 | sort -k2 -n -r | head -20

Вывод — дерево с двумя колонками: собственное время модуля и суммарное с зависимостями.

import time: self [us] | cumulative | imported package
import time:       716 |       1270 |   json.scanner
import time:       593 |       9077 |   json.decoder
import time:      1065 |      10795 | json

Смотреть надо на cumulative у верхнеуровневых: там сразу видно, что половину старта занимает какая-нибудь библиотека, импортированная ради одной функции в редкой ветке.

Лечение выводится из следствия 4: раз import — обычная инструкция, её можно перенести туда, где она нужна.

# было: на верхнем уровне
import pandas as pd

# стало: внутри функции, которая одна им и пользуется
def export_excel(rows):
    import pandas as pd
    ...

Плата — микросекунды на поиск в sys.modules при каждом вызове (следствие 1 гарантирует, что тело исполнится только раз). Для функции, вызываемой в цикле, лучше вынести обратно; для редкой ветки выигрыш прямой.

Где это критично: CLI-утилиты, serverless с холодным стартом, тесты, где интерпретатор поднимается многократно. В долгоживущем сервисе — не важно вообще.

💻 терминалПроверь в REPLЧетыре фрагмента плюс reload, удаление из sys.modules и __main__

Скопируй фрагменты и прогони, сверяясь с предсказанием. Затем:

import sys, json
del sys.modules["json"]
import json as json2
print(json is json2)
# Почему False? Что теперь с isinstance для классов оттуда?

# в файле script.py:
print(__name__)
# Что печатается при запуске и что при импорте?
# И почему без этой проверки ломается multiprocessing со spawn?

import importlib, mymod
from mymod import f
importlib.reload(mymod)
print(f is mymod.f)
# Почему False? Через какое следствие это объясняется?

import sys
print(len(sys.modules))
# Сколько модулей уже загружено в пустом интерпретаторе?

Третий вопрос — прямая проверка следствия 2: reload создаёт новые функции в словаре модуля, а привязка в твоём неймспейсе указывает на старую.

исходникиimportlib и unittest.mockГде механизм виден как проектное решение, а не как теория

Lib/importlib/_bootstrap.py. Весь механизм импорта, написанный на Python и вмораживаемый в интерпретатор при сборке. Найди _find_and_load — там буквально видно следствие 3: объект модуля кладётся в sys.modules перед вызовом exec_module, и рядом стоит комментарий про циклические импорты.

Lib/unittest/mock.py, функция _get_target. Здесь видно, что patch("a.b.c") — это просто разбор строки на модуль и имя атрибута с последующим setattr. Никакой магии: библиотека переписывает запись в словаре, а какую именно — решаешь ты строкой. Понимание следствия 2 закрывает вопрос «где патчить» навсегда.

Lib/site.py. Модуль, который исполняется при каждом старте интерпретатора и собирает sys.path. Полезно один раз прочитать, чтобы sys.path перестал быть данностью.

L2Что происходит между import и исполнениемFinders, loaders, sys.modules и кэш байткода

Из L1 ты знаешь, что импорт исполняет файл и кэширует объект. Здесь — что между этим.

Последовательность при import pkg.mod:

  1. Проверка sys.modules["pkg.mod"]. Есть — вернуть и закончить.
  2. Импортировать родителя pkg, если ещё нет.
  3. Пройти по sys.meta_path — списку искателей. Каждый спрашивается, может ли он найти модуль; стандартных три, последний ищет по sys.path.
  4. Искатель возвращает спецификацию: имя, загрузчик, путь к файлу.
  5. Создаётся пустой объект модуля и кладётся в sys.modules.
  6. Загрузчик исполняет код в словаре модуля. Если бросил исключение — запись из sys.modules удаляется.

Шаг 5 перед шагом 6 — то самое место, из которого растёт «partially initialized module».

Кэш байткода. Файл компилируется в байткод и складывается в __pycache__ с отпечатком исходника — по умолчанию это время изменения и размер. Значит повторный запуск пропускает компиляцию, но не исполнение: тело модуля выполняется всегда. Ускорить старт удалением __pycache__ нельзя, ускорить — можно только не импортируя лишнего.

import sys
print(len(sys.modules))
# → около 60 модулей уже загружено в «пустом» интерпретаторе

import json
print(json.__spec__)
# → ModuleSpec(name='json', loader=..., origin='.../json/__init__.py')
L3Где это в исходниках CPython_bootstrap.py, import.c, PEP 451

Тег v3.11.15.

  • Lib/importlib/_bootstrap.py, функции _find_and_load и _load — вся последовательность из L2 на читаемом Python.
  • Python/import.c — C-часть: работа с sys.modules, блокировки импорта, инициализация встроенных модулей.
  • Lib/importlib/_bootstrap_external.py — поиск по файловой системе и кэш байткода: там же формат заголовка .pyc.
  • PEP 451 — спецификации модулей; текущая архитектура искателей и загрузчиков описана там.
связиКуда это ведётL01, L05, L14 · контраст с #include и статической инициализацией C++
корень R7 · Импорт — это исполнение кода
← основа L01 · Имя, объект, ссылка — from x import y создаёт привязку, и отсюда «где патчить»
← основа L05 · dict и хешируемость — неймспейс модуля это словарь
↔ контраст #include в C: текстовая вставка и include guard против кэша в sys.modules
↔ контраст Статическая инициализация C++: тот же «конструктор до main» и та же проблема порядка
→ дальше L16 · Пакеты и переписывание — что происходит уровнем выше, при установке
чего здесь нет Namespace packages и структура дистрибутивов — в L16
источникиЧто почитать_bootstrap.py 🟥 · The import system 🟧 · PEP 451 🟦