mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
91 lines
4.8 KiB
Python
91 lines
4.8 KiB
Python
"""Загрузка модуля интеграции по пути, без Home Assistant (issue #394).
|
|
|
|
Зачем не обычный импорт: часть чистых тестов читает модуль под своим именем
|
|
(`hp_validation`) либо под каноническим, чтобы работали относительные импорты
|
|
внутри него. Родительские пакеты при этом должны существовать — в окружении с
|
|
Home Assistant это настоящий пакет, без него пустышки ставит `conftest.py`.
|
|
|
|
Здесь используется отдельный временный 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):
|
|
"""Загрузить модуль по пути под указанным именем и убрать за собой.
|
|
|
|
Имя значимо: относительные импорты внутри модуля резолвятся только тогда,
|
|
когда модуль знает, какому пакету принадлежит.
|
|
|
|
Регистрация в `sys.modules` обязательна на время `exec_module` и вредна
|
|
после (#398). Она переживала вызов и доставалась всей сессии — тот же
|
|
класс, что #389: загрузчик 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)
|
|
|
|
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 _is_custom_components_entry(key) or key == name
|
|
]:
|
|
del sys.modules[key]
|
|
sys.modules.update(saved)
|
|
return module
|