Fix isolated pure-module test loading

Issue: #465
User-Visible: no
This commit is contained in:
Matysh
2026-09-05 21:01:29 +03:00
parent 0a955f5df5
commit 855b62729d
2 changed files with 147 additions and 21 deletions
+49 -12
View File
@@ -5,21 +5,39 @@
внутри него. Родительские пакеты при этом должны существовать — в окружении с
Home Assistant это настоящий пакет, без него пустышки ставит `conftest.py`.
Ставить пустышки здесь, в момент загрузки, было бы ошибкой: именно так они
переживали свой тест и доставались всей сессии. В #389 это стоило пяти часов
красного `dev` — HA получал пустышку вместо интеграции и не мог её поднять.
Здесь используется отдельный временный namespace. Он обязательно снимается в
`finally`, после чего исходное состояние импорт-машины восстанавливается по
идентичности объектов. Это принципиальное отличие от утечки пустышек из #389,
из-за которой HA получал заглушку вместо интеграции и не мог её поднять.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
REPO = Path(__file__).resolve().parent.parent
PACKAGE_ROOT = REPO / "custom_components"
HOUSEPLAN_ROOT = PACKAGE_ROOT / "houseplan"
def _is_custom_components_entry(key: str) -> bool:
"""Принадлежит ли имя ровно namespace `custom_components`."""
return key == PACKAGE_ROOT.name or (
key.startswith(PACKAGE_ROOT.name)
and key[len(PACKAGE_ROOT.name):].startswith(".")
)
def _namespace_package(name: str, path: Path) -> ModuleType:
"""Минимальный package, достаточный для абсолютных/относительных импортов."""
module = ModuleType(name)
module.__package__ = name
module.__path__ = [str(path)]
return module
def load_pure(name: str, file: Path):
"""Загрузить модуль по пути под указанным именем и убрать за собой.
@@ -29,25 +47,44 @@ def load_pure(name: str, file: Path):
Регистрация в `sys.modules` обязательна на время `exec_module` и вредна
после (#398). Она переживала вызов и доставалась всей сессии — тот же
класс, что #389: загрузчик Home Assistant получал бы модуль, собранный
мимо него, а объекты классов одного файла оказывались бы разными. Снимать
только собственное имя мало: относительные импорты подтягивают соседей
(`junction_limits` тянет `wall_segment_model` и
`coordinate_canonicalization`), поэтому снимается вся разница под
префиксом `custom_components`, появившаяся за время загрузки.
мимо него, а объекты классов одного файла оказывались бы разными.
`conftest.py` ставит пустышки родительских пакетов, когда Home Assistant
недоступен, — они принадлежат ему и здесь не трогаются.
В окружении с установленным HA нельзя позволять импорту соседнего модуля
запустить настоящий `houseplan/__init__.py`: он может вернуться к ещё не
исполненному pure-модулю и образовать цикл (#465). Поэтому весь namespace
`custom_components` временно заменяется двумя package-пустышками с
настоящими путями. В `finally` временное дерево удаляется, а полный снимок
исходных имён и объектов восстанавливается. Это сохраняет и пустышки
`conftest.py`, и уже загруженные настоящие модули без подмены по identity.
"""
before = frozenset(sys.modules)
spec = importlib.util.spec_from_file_location(name, file)
if spec is None or spec.loader is None:
raise ImportError(f"cannot create import spec for {name} from {file}")
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
saved = {
key: sys.modules[key]
for key in before
if _is_custom_components_entry(key) or key == name
}
try:
for key in tuple(saved):
sys.modules.pop(key, None)
package = _namespace_package(PACKAGE_ROOT.name, PACKAGE_ROOT)
houseplan = _namespace_package(
f"{PACKAGE_ROOT.name}.houseplan", HOUSEPLAN_ROOT)
package.houseplan = houseplan
sys.modules[PACKAGE_ROOT.name] = package
sys.modules[f"{PACKAGE_ROOT.name}.houseplan"] = houseplan
sys.modules[name] = module
spec.loader.exec_module(module)
finally:
for key in [
key for key in sys.modules
if key not in before and key.startswith(PACKAGE_ROOT.name)
if _is_custom_components_entry(key) or key == name
]:
del sys.modules[key]
sys.modules.update(saved)
return module
+98 -9
View File
@@ -176,17 +176,34 @@ def test_issue_398_pure_imports_leaves_sys_modules_as_it_found_it():
from tests_backend.pure_imports import HOUSEPLAN_ROOT, load_pure
before = sorted(k for k in sys.modules if k.startswith("custom_components"))
before = {
key: value for key, value in sys.modules.items()
if key == "custom_components" or key.startswith("custom_components.")
}
def assert_registry_unchanged() -> None:
after = {
key: value for key, value in sys.modules.items()
if key == "custom_components" or key.startswith("custom_components.")
}
assert after.keys() == before.keys(), (
"load_pure изменил набор custom_components-модулей: "
f"added={sorted(after.keys() - before.keys())}, "
f"removed={sorted(before.keys() - after.keys())}"
)
replaced = [key for key, value in before.items() if after[key] is not value]
assert replaced == [], (
"load_pure восстановил имена, но подменил объекты: "
f"{sorted(replaced)}"
)
module = load_pure(
"custom_components.houseplan.junction_limits",
HOUSEPLAN_ROOT / "junction_limits.py",
)
assert dir(module), "модуль обязан быть рабочим после очистки"
after = sorted(k for k in sys.modules if k.startswith("custom_components"))
assert after == before, (
f"load_pure оставил в sys.modules: {sorted(set(after) - set(before))} — "
"относительные импорты подтягивают соседей, снимать нужно всю разницу"
)
assert module.JunctionLimitError
assert module.validate_junction_limits
assert_registry_unchanged()
# Повторный вызов обязан работать так же: очистка не должна ломать
# следующий заход (тесты вызывают load_pure по нескольку раз за сессию).
@@ -194,8 +211,80 @@ def test_issue_398_pure_imports_leaves_sys_modules_as_it_found_it():
"custom_components.houseplan.junction_limits",
HOUSEPLAN_ROOT / "junction_limits.py",
)
assert dir(again)
assert sorted(k for k in sys.modules if k.startswith("custom_components")) == before
assert again.JunctionLimitError
assert_registry_unchanged()
# Standalone-имя тоже живёт только во время exec_module. Прежняя очистка
# следила лишь за custom_components и оставляла такой target в реестре.
standalone = "houseplan_quality_const_probe"
assert standalone not in sys.modules
const = load_pure(standalone, HOUSEPLAN_ROOT / "const.py")
assert const.DOMAIN == "houseplan"
assert standalone not in sys.modules
assert_registry_unchanged()
def test_issue_465_pure_imports_is_order_independent():
"""Чистый процесс без parent package не должен запускать реальный __init__.
Отдельный процесс делает регрессию независимой от порядка collection:
именно общий прогон раньше случайно маскировал #465, заранее импортируя
настоящий пакет. Там же проверяется восстановление заранее существующих
sentinel-объектов по identity, а не только совпадение имён.
"""
import subprocess
import sys
import textwrap
probe = textwrap.dedent(
"""
import sys
from types import ModuleType
from tests_backend.pure_imports import HOUSEPLAN_ROOT, load_pure
modules = sys.modules
prefix = "custom_components"
target = "custom_components.houseplan.junction_limits"
for key in tuple(modules):
if key == prefix or key.startswith(prefix + "."):
modules.pop(key, None)
for _ in range(2):
loaded = load_pure(target, HOUSEPLAN_ROOT / "junction_limits.py")
assert loaded.JunctionLimitError
assert loaded.validate_junction_limits
assert not any(
key == prefix or key.startswith(prefix + ".")
for key in modules
)
sentinels = {
prefix: ModuleType(prefix),
f"{prefix}.houseplan": ModuleType(f"{prefix}.houseplan"),
target: ModuleType(target),
}
modules.update(sentinels)
loaded = load_pure(target, HOUSEPLAN_ROOT / "junction_limits.py")
assert loaded is not sentinels[target]
assert {
key for key in modules
if key == prefix or key.startswith(prefix + ".")
} == sentinels.keys()
assert all(modules[key] is value for key, value in sentinels.items())
"""
)
result = subprocess.run(
[sys.executable, "-c", probe],
cwd=REPO,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, (
"isolated pure import failed:\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
# --- #436: файл, которому нужен Home Assistant, обязан это объявить ---------