mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
@@ -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
|
||||
|
||||
@@ -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, обязан это объявить ---------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user