From 855b62729dc4f7f2ab084765758b02aacb145ae1 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 5 Sep 2026 21:01:29 +0300 Subject: [PATCH] Fix isolated pure-module test loading Issue: #465 User-Visible: no --- tests_backend/pure_imports.py | 61 ++++++++++++--- tests_backend/test_backend_quality.py | 107 +++++++++++++++++++++++--- 2 files changed, 147 insertions(+), 21 deletions(-) diff --git a/tests_backend/pure_imports.py b/tests_backend/pure_imports.py index 2880fbbb..fab0046d 100644 --- a/tests_backend/pure_imports.py +++ b/tests_backend/pure_imports.py @@ -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 diff --git a/tests_backend/test_backend_quality.py b/tests_backend/test_backend_quality.py index a610d685..f2902398 100644 --- a/tests_backend/test_backend_quality.py +++ b/tests_backend/test_backend_quality.py @@ -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, обязан это объявить ---------