mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
fix: stop the schema dump from poisoning the HA harness
Красный backend на dev — это два независимых дефекта, и ни один не был виден в диффе. Первый, 85 падений. scripts/dump-config-schema.py подменяет custom_components и custom_components.houseplan пустышками, чтобы прочитать схему без Home Assistant, и оставляет их в sys.modules навсегда. Вызывает его в том числе pytest: tests_backend/test_config_schema_manifest.py идёт первым по алфавиту. Дальше HA просил у загрузчика custom_components.houseplan, получал пустышку без async_setup и отказывался поднимать интеграцию — «No setup or config entry setup function defined». Каждый тест харнесса падал на _setup с «assert False», и ни один не намекал на причину: подмена работает для подмодулей, потому что __path__ у пустышки настоящий. Подмена не убрана — без неё скрипт не выполнит свою задачу. Она стала обратимой: sys.modules снимается до и возвращается после, включая отсутствие ключа. Обратимость закреплена тестом. Второй, 1 падение. test_furniture_flip_flags_survive_coordinate_ canonicalization_unchanged требовал CONFIG_SCHEMA(result) == result, но схема на минимальном конфиге достраивает markers и settings и приводит целые к float — тождества там нет и не было. Проверяется теперь неподвижная точка: повторная валидация не меняет канонический вид, а флаги её переживают. Диагностика шла через CI: в песочнице HA-харнесс не поднять, поэтому временная ветка experiment/389-diag печатала, что именно видит загрузчик. Она показала модуль без __file__ и без единого атрибута — namespace-подобную пустышку, — и это вывело на подмену. Issue: #389 User-Visible: no
This commit is contained in:
@@ -28,6 +28,9 @@ REPO = Path(__file__).resolve().parent.parent
|
||||
OUT = REPO / "scripts" / "config-schema.json"
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _safe_repr(value) -> str:
|
||||
"""repr() without memory addresses — the manifest must be deterministic."""
|
||||
if callable(value) and hasattr(value, "__qualname__"):
|
||||
@@ -35,30 +38,66 @@ def _safe_repr(value) -> str:
|
||||
return re.sub(r" at 0x[0-9a-fA-F]+", "", repr(value))
|
||||
|
||||
|
||||
# Ключи, которые подмена трогает. Всё, что здесь перечислено, обязано быть
|
||||
# возвращено на место: см. _load_validation.
|
||||
_STUBBED = (
|
||||
"custom_components",
|
||||
"custom_components.houseplan",
|
||||
"custom_components.houseplan.coordinate_canonicalization",
|
||||
"custom_components.houseplan.validation",
|
||||
)
|
||||
|
||||
|
||||
def _load_validation():
|
||||
for name, path in (
|
||||
("custom_components", REPO / "custom_components"),
|
||||
("custom_components.houseplan", REPO / "custom_components" / "houseplan"),
|
||||
):
|
||||
module = types.ModuleType(name)
|
||||
module.__path__ = [str(path)]
|
||||
sys.modules[name] = module
|
||||
"""Загрузить `validation.py` без Home Assistant — и не сломать всё остальное.
|
||||
|
||||
def load(name: str, file: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, file)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
Подмена родительских пакетов пустышками нужна затем, что обычный импорт
|
||||
выполнил бы `custom_components/houseplan/__init__.py`, а тот тянет весь HA.
|
||||
Скрипт обязан работать и там, где HA нет вовсе.
|
||||
|
||||
load(
|
||||
"custom_components.houseplan.coordinate_canonicalization",
|
||||
REPO / "custom_components" / "houseplan" / "coordinate_canonicalization.py",
|
||||
)
|
||||
return load(
|
||||
"custom_components.houseplan.validation",
|
||||
REPO / "custom_components" / "houseplan" / "validation.py",
|
||||
)
|
||||
Но у подмены есть цена, и она однажды была заплачена (#389). Пустышка
|
||||
оставалась в `sys.modules` навсегда, а вызывает этот код в том числе
|
||||
pytest — `tests_backend/test_config_schema_manifest.py` идёт первым по
|
||||
алфавиту. Дальше HA просил у загрузчика `custom_components.houseplan`,
|
||||
получал пустышку без `async_setup` и отказывался поднимать интеграцию:
|
||||
«No setup or config entry setup function defined». Восемьдесят пять тестов
|
||||
HA-харнесса падали на ровном месте, и ни один не намекал на причину.
|
||||
|
||||
Подмена не убирается совсем — без неё скрипт не выполнит свою задачу. Она
|
||||
становится обратимой: прежнее содержимое `sys.modules` снимается до и
|
||||
возвращается после, включая отсутствие ключа.
|
||||
"""
|
||||
saved = {name: sys.modules.get(name, _MISSING) for name in _STUBBED}
|
||||
try:
|
||||
for name, path in (
|
||||
("custom_components", REPO / "custom_components"),
|
||||
("custom_components.houseplan", REPO / "custom_components" / "houseplan"),
|
||||
):
|
||||
module = types.ModuleType(name)
|
||||
module.__path__ = [str(path)]
|
||||
sys.modules[name] = module
|
||||
|
||||
def load(name: str, file: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, file)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
load(
|
||||
"custom_components.houseplan.coordinate_canonicalization",
|
||||
REPO / "custom_components" / "houseplan" / "coordinate_canonicalization.py",
|
||||
)
|
||||
return load(
|
||||
"custom_components.houseplan.validation",
|
||||
REPO / "custom_components" / "houseplan" / "validation.py",
|
||||
)
|
||||
finally:
|
||||
for name, previous in saved.items():
|
||||
if previous is _MISSING:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = previous
|
||||
|
||||
|
||||
def _render_default(value):
|
||||
|
||||
@@ -25,8 +25,9 @@ def _load_dump_module():
|
||||
|
||||
|
||||
def _validation():
|
||||
_load_dump_module() # its importer registers the stubbed packages
|
||||
return sys.modules["custom_components.houseplan.validation"]
|
||||
# Модуль берётся возвращённым значением, а не из sys.modules: подмена
|
||||
# пакетов теперь обратима и после загрузки ключей там не остаётся (#389).
|
||||
return _load_dump_module()._load_validation()
|
||||
|
||||
|
||||
def test_issue_33_manifest_is_fresh_and_deterministic():
|
||||
@@ -80,3 +81,29 @@ def test_issue_33_future_fields_round_trip_exactly():
|
||||
assert validated["spaces"][0]["future_space_field"] == "kept"
|
||||
assert validated["spaces"][0]["settings"]["future_display"] is True
|
||||
assert validated["markers"][0]["future_marker_field"] == {"nested": "kept"}
|
||||
|
||||
|
||||
def test_issue_389_the_dump_leaves_sys_modules_as_it_found_it():
|
||||
"""Подмена пакетов обязана быть обратимой.
|
||||
|
||||
Скрипт дампа подменяет `custom_components` и `custom_components.houseplan`
|
||||
пустышками, чтобы прочитать схему без Home Assistant. Пока подмена
|
||||
оставалась в `sys.modules`, HA получал пакет без `async_setup` и отказывался
|
||||
поднимать интеграцию — 85 тестов харнесса падали с «assert False», и ни
|
||||
один не указывал на причину. Тест держит именно обратимость, а не факт
|
||||
подмены: без неё скрипт свою работу не сделает.
|
||||
"""
|
||||
keys = (
|
||||
"custom_components",
|
||||
"custom_components.houseplan",
|
||||
"custom_components.houseplan.validation",
|
||||
"custom_components.houseplan.coordinate_canonicalization",
|
||||
)
|
||||
missing = object()
|
||||
before = {name: sys.modules.get(name, missing) for name in keys}
|
||||
|
||||
_validation()
|
||||
|
||||
for name in keys:
|
||||
after = sys.modules.get(name, missing)
|
||||
assert after is before[name], f"{name}: подмена не возвращена на место"
|
||||
|
||||
@@ -117,7 +117,15 @@ def test_furniture_flip_flags_survive_coordinate_canonicalization_unchanged() ->
|
||||
furniture = result["spaces"][0]["decor"][0]
|
||||
assert furniture["flip_h"] is True
|
||||
assert furniture["flip_v"] is False
|
||||
assert CONFIG_SCHEMA(result) == result
|
||||
# Схема не тождественна на минимальном конфиге: она достраивает `markers`
|
||||
# и `settings` и приводит целые к float. Поэтому проверяется не равенство
|
||||
# с входом, а неподвижная точка — канонический вид схемы не «плывёт» от
|
||||
# повторной валидации, и флаги её переживают (#389).
|
||||
validated = CONFIG_SCHEMA(result)
|
||||
assert CONFIG_SCHEMA(validated) == validated
|
||||
mirrored = validated["spaces"][0]["decor"][0]
|
||||
assert mirrored["flip_h"] is True
|
||||
assert mirrored["flip_v"] is False
|
||||
|
||||
|
||||
def test_optimize_roundtrip_fixture_has_one_backend_canonical_target() -> None:
|
||||
|
||||
Reference in New Issue
Block a user