From 32c70ecd194533b81ec1452c414c7bd9bcb466a5 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 30 Aug 2026 18:31:36 +0300 Subject: [PATCH] fix: stop the schema dump from poisoning the HA harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Красный 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 --- scripts/dump-config-schema.py | 81 ++++++++++++++----- tests_backend/test_config_schema_manifest.py | 31 ++++++- .../test_coordinate_canonicalization.py | 10 ++- 3 files changed, 98 insertions(+), 24 deletions(-) diff --git a/scripts/dump-config-schema.py b/scripts/dump-config-schema.py index 3e111f1f..868452c6 100644 --- a/scripts/dump-config-schema.py +++ b/scripts/dump-config-schema.py @@ -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): diff --git a/tests_backend/test_config_schema_manifest.py b/tests_backend/test_config_schema_manifest.py index 4dc9d676..469b299f 100644 --- a/tests_backend/test_config_schema_manifest.py +++ b/tests_backend/test_config_schema_manifest.py @@ -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}: подмена не возвращена на место" diff --git a/tests_backend/test_coordinate_canonicalization.py b/tests_backend/test_coordinate_canonicalization.py index d9fa6826..36c7027e 100644 --- a/tests_backend/test_coordinate_canonicalization.py +++ b/tests_backend/test_coordinate_canonicalization.py @@ -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: