import не подключает файл, а исполняет его — ровно один раз за процесс. Отсюда общее состояние модулей, циклические импорты, «где патчить mock» и половина странностей, которые видит QA.
Зафиксируй вывод и причину до того, как откроешь ответ.
# pkg/__init__.py содержит print("исполняется")
import pkg
import pkg
import pkgисполняетсяТри импорта, одна строка вывода.# pkg/a.py: from pkg.b import helper
# pkg/b.py: from pkg.a import go
import pkg.aImportError: cannot import name 'go' from partially
initialized module 'pkg.a' (most likely due to a
circular import)Ключевое слово — partially initialized.# 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())отчёт: настоящее время
отчёт: ФЕЙКПатчим ту же функцию двумя путями. Работает только один.import pkg
pkg.VERSION = "изменено"
import pkg as pkg2
print(pkg2.VERSION)измененоДругое имя, другой импорт — то же состояние.Модуль — объект. Его пространство имён — обычный словарь, тот самый из урока 05.
import исполняет файл сверху донизу в этом словаре — как тело функции или класса.
Результат кэшируется в sys.modules по имени. Повторный импорт достаёт готовый объект и не исполняет ничего.
Фрагменты A и D. Первый импорт исполняет файл и кладёт объект в sys.modules; все последующие возвращают его же. Значит любое изменение атрибута модуля видно везде, где его импортировали, — независимо от того, под каким именем.
Отсюда две вещи сразу. Хорошая: модуль — готовый синглтон, и отдельный паттерн для него не нужен. Плохая: всё на верхнем уровне модуля живёт весь процесс — кэши, соединения, счётчики, изменённые атрибуты классов. Это и есть источник «тест проходит один и падает в наборе»: состояние протекает между тестами, а порядок лишь проявляет проблему.
from x import y связывает имя, а не ссылается на модульЗдесь самая частая мёртвая зона, и фрагмент C её вскрывает. from src import now делает буквально следующее: импортирует src, достаёт из его словаря now и кладёт под тем же именем в словарь модуля-потребителя. Получаются две независимые записи, указывающие на один объект.
Подмена src.now переписывает запись в словаре src. Запись в словаре consumer при этом не меняется — она по-прежнему указывает на старую функцию. Поэтому патчить надо по месту использования: patch("consumer.now").
Это не свойство библиотеки моков, а прямое следствие того, что имя — привязка (урок 01), а неймспейс модуля — словарь. И то же объясняет, почему import src с обращением src.now() патчится и так и так: обращение через модуль читает словарь src каждый раз.
Фрагмент B. Чтобы защититься от бесконечной рекурсии, Python кладёт объект модуля в sys.modules до исполнения его тела. Значит при цикле второй импорт получает объект, в котором выполнена только часть строк.
Дальше зависит от формы записи. from pkg.a import go требует имя прямо сейчас — и падает, если до его определения дело ещё не дошло. А import pkg.a с обращением pkg.a.go() внутри функции сработает: к моменту вызова модуль уже дособран.
Отсюда три способа починки, и все выводятся из механизма: перенести импорт внутрь функции, импортировать модуль целиком вместо имени из него, или выделить общую часть в третий модуль, разорвав цикл.
Раз это исполнение обычного кода, при импорте может произойти всё: открыться соединение, прочитаться файл, зарегистрироваться обработчик, уйти запрос в сеть. Язык этого не запрещает и не может запретить.
Практических следствий два. Первое — время старта: тяжёлая работа на верхнем уровне модуля выполняется при каждом запуске процесса, и в CLI-утилите или лямбде это видно секундомером. Второе — порядок: сайд-эффект случается в момент первого импорта, а он зависит от того, кто кого импортировал раньше.
sys.modules можно менять руками — и на этом стоят подмены в тестах и ленивые загрузчики. Но удаление записи не «выгружает» модуль: старый объект жив, пока на него ссылаются, и второй импорт создаст второй модуль с отдельными классами. Отсюда загадочное isinstance, возвращающее False для объекта «того же» класса.
importlib.reload не перезагружает мир. Он повторно исполняет тело в том же словаре, но уже созданные объекты и классы остаются старыми, а другие модули продолжают держать привязки к прежним функциям — прямо по следствию 2.
Путь поиска — не только sys.path. Механизм расширяемый: sys.meta_path и sys.path_hooks позволяют импортировать откуда угодно — из архива, из сети, из базы. На этом стоят zip-приложения и системы плагинов.
Корень R7: импорт — это исполнение. Решение принято в самом начале и вытекает из общей модели: раз def и class исполняются, а модуль — просто файл с кодом, то и модуль исполняется. Никакой отдельной фазы «подключения» в языке нет.
Отсюда и то, что import — обычная инструкция, а не директива препроцессора: его можно написать внутри функции, под if, в цикле. Ровно этим и пользуются для разрыва циклов и ленивой загрузки тяжёлых зависимостей.
Цена — отсутствие статической картины программы. Компилятор не знает, что откуда придёт; инструменты вынуждены угадывать; ошибка в импорте обнаруживается только при запуске. Взамен получена расширяемость: подменить механизм импорта — обычная задача, а не хак.
Ближайшая точная аналогия — статическая инициализация в C++. Глобальный объект с конструктором выполняет код до main, ровно один раз, и порядок между единицами трансляции не определён — знаменитая «static initialization order fiasco». Модуль Python ведёт себя так же: код верхнего уровня — это конструктор, который отработает один раз при первом обращении.
И проблема та же по сути: если один модуль на верхнем уровне зависит от того, что уже отработал другой, порядок начинает иметь значение — а определяется он тем, кто кого импортировал первым. Лечение тоже совпадает по духу: в C++ применяют ленивую инициализацию через функцию с локальным статиком, в Python — переносят работу из тела модуля в функцию, вызываемую явно.
Разница в одном и в пользу Python: у него порядок хотя бы детерминирован и виден в коде, а не отдан на откуп компоновщику.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что модуль попадает в 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).
Чего не делать: переставлять импорты местами до тех пор, пока не заработает. Это чинит симптом, и следующий человек, добавивший строку, сломает всё обратно.
Потому что 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 * — плохо?Потому что он делает невозможным ответ на вопрос «откуда взялось это имя», причём и для человека, и для инструментов.
Конкретные последствия, по убыванию вредности:
__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 ставились отдельными дистрибутивами.
Отличия, которые кусаются:
sys.path. Случайно совпавшее имя каталога где-то ещё — и содержимое пакета молча смешивается.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 с его #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 с холодным стартом, тесты, где интерпретатор поднимается многократно. В долгоживущем сервисе — не важно вообще.
Скопируй фрагменты и прогони, сверяясь с предсказанием. Затем:
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 создаёт новые функции в словаре модуля, а привязка в твоём неймспейсе указывает на старую.
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 перестал быть данностью.
Из L1 ты знаешь, что импорт исполняет файл и кэширует объект. Здесь — что между этим.
Последовательность при import pkg.mod:
sys.modules["pkg.mod"]. Есть — вернуть и закончить.pkg, если ещё нет.sys.meta_path — списку искателей. Каждый спрашивается, может ли он найти модуль; стандартных три, последний ищет по sys.path.sys.modules.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')
Тег 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.from x import y создаёт привязку, и отсюда «где патчить»#include в C: текстовая вставка и include guard против кэша в sys.modulesLib/importlib/_bootstrap.py — читать функцию _find_and_load целиком. Пятьдесят строк, объясняющие и кэш, и циклические импорты.sys.modules и про поиск. Остальное справочник.