test: put the package stub in one place, under an honest condition

Чистые тесты подменяли custom_components и custom_components.houseplan
пустышками и не убирали их никогда. В CI это не стреляло только потому, что
настоящий пакет успевал импортироваться из файла, который идёт раньше по
алфавиту: условие «если ещё не импортирован» оказывалось ложным. То есть
корректность HA-харнесса держалась на именах файлов в каталоге, и хватило бы
переименования, чтобы получить #389 заново.

Подмена переехала в conftest и стала условной по единственному честному
признаку: есть Home Assistant — работаем с настоящим пакетом и не подменяем
ничего; нет — HA-тесты и так пропущены, ломать нечего.

Первым заходом я делал подмену обратимой прямо в тестах, контекстным
менеджером. Замер показал, что так теряется работоспособность
test_wall_segment_model в песочнице: он импортирует пакет обычным способом и
жил как раз за счёт чужой пустышки. Развилка в conftest сохраняет оба
окружения и убирает зависимость от порядка файлов.

Проверено в обе стороны: в песочнице 240 passed, а в эмуляции «HA есть»
объект пакета после прогона тот же, что был до.

Issue: #394
User-Visible: no
This commit is contained in:
Matysh
2026-08-30 20:11:31 +03:00
parent a3dcee5241
commit 8dfb0a9e36
5 changed files with 108 additions and 39 deletions
+23
View File
@@ -26,3 +26,26 @@ test('backend-тесты не правят sys.path (#393)', () => {
+ ' Читать модуль без Home Assistant можно и без этого — см. test_trails.py:'
+ ' текст файла и exec нужного среза.');
});
test('пакет интеграции подменяет только conftest и только без HA (#394)', () => {
// Пустышка вместо `custom_components.houseplan` — единственный способ читать
// подмодули без Home Assistant, и запретить её нельзя. Но место у неё одно:
// conftest, где решение принимается по честному признаку «есть ли HA».
//
// Когда её ставили сами тесты под условием «если ещё не импортирован», в CI
// она не срабатывала лишь потому, что настоящий пакет успевал импортироваться
// из файла, который идёт раньше по алфавиту. Корректность харнесса держалась
// на именах файлов; чем это кончается, показал #389.
const assigns = /sys\.modules\[\s*(['"])custom_components/;
const offenders = files().filter((name) => name !== 'conftest.py' && assigns.test(read(name)));
assert.deepEqual(offenders, [],
'подмена пакета интеграции живёт в tests_backend/conftest.py и только там:'
+ ' там она условная (нет Home Assistant — нечего ломать), а в тесте она'
+ ' переживает свой тест и достаётся всей сессии (#389, #394).');
const conftest = read('conftest.py');
assert.match(conftest, /if not HAS_HA:/,
'подмена в conftest обязана быть под условием отсутствия Home Assistant');
const stub = conftest.slice(conftest.indexOf('if not HAS_HA:'));
assert.match(stub, /sys\.modules\[_name\] = _module/);
});
+30
View File
@@ -10,6 +10,36 @@ except ImportError:
collect_ignore_glob = [] if HAS_HA else ["test_ha_*.py"]
# Пакет интеграции без Home Assistant не импортируется: его __init__.py тянет
# весь HA. Чистым тестам нужны только подмодули, поэтому в окружении БЕЗ HA
# родительские пакеты подменяются пустышками с настоящим __path__ — подмодули
# после этого читаются, а тяжёлый __init__.py не исполняется.
#
# Подмена живёт здесь, а не в тестах, и это главное решение issue #394. Раньше
# её ставили сами тесты, под условием «если ещё не импортирован», и в CI она не
# срабатывала лишь потому, что настоящий пакет успевал импортироваться из файла,
# который идёт раньше по алфавиту. Корректность HA-харнесса держалась на именах
# файлов в каталоге; чем это кончается, показал #389 — 85 тестов упали с голым
# `assert False`, потому что HA получил пустышку вместо интеграции.
#
# Теперь развилка явная и по единственному честному признаку: есть Home
# Assistant — работаем с настоящим пакетом и ничего не подменяем; нет — значит
# HA-тесты и так пропущены, и подменять безопасно, ломать нечего.
if not HAS_HA:
import sys
import types
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
for _name, _path in (
("custom_components", _ROOT / "custom_components"),
("custom_components.houseplan", _ROOT / "custom_components" / "houseplan"),
):
if _name not in sys.modules:
_module = types.ModuleType(_name)
_module.__path__ = [str(_path)]
sys.modules[_name] = _module
if HAS_HA:
import shutil
from pathlib import Path
+33
View File
@@ -0,0 +1,33 @@
"""Загрузка модуля интеграции по пути, без Home Assistant (issue #394).
Зачем не обычный импорт: часть чистых тестов читает модуль под своим именем
(`hp_validation`) либо под каноническим, чтобы работали относительные импорты
внутри него. Родительские пакеты при этом должны существовать — в окружении с
Home Assistant это настоящий пакет, без него пустышки ставит `conftest.py`.
Ставить пустышки здесь, в момент загрузки, было бы ошибкой: именно так они
переживали свой тест и доставались всей сессии. В #389 это стоило пяти часов
красного `dev` — HA получал пустышку вместо интеграции и не мог её поднять.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
PACKAGE_ROOT = REPO / "custom_components"
HOUSEPLAN_ROOT = PACKAGE_ROOT / "houseplan"
def load_pure(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
+12 -21
View File
@@ -6,38 +6,29 @@ fixtures to the TypeScript checks and to this module and demands the same
verdict, because two implementations of one rule are worth nothing if they can
disagree.
"""
import importlib.util
import json
import os
import subprocess
import sys
import types
import pytest
from pathlib import Path
from pure_imports import load_pure
_ROOT = os.path.dirname(os.path.dirname(__file__))
_PACKAGE_ROOT = os.path.join(_ROOT, "custom_components")
_HOUSEPLAN_ROOT = os.path.join(_PACKAGE_ROOT, "houseplan")
if "custom_components" not in sys.modules:
package = types.ModuleType("custom_components")
package.__path__ = [_PACKAGE_ROOT]
sys.modules["custom_components"] = package
if "custom_components.houseplan" not in sys.modules:
package = types.ModuleType("custom_components.houseplan")
package.__path__ = [_HOUSEPLAN_ROOT]
sys.modules["custom_components.houseplan"] = package
# Loaded under its canonical package name: the module imports the migration
# mirror relatively (`from .wall_segment_model import ...`), which only resolves
# when the module knows the package it belongs to.
_PATH = os.path.join(_HOUSEPLAN_ROOT, "junction_limits.py")
_spec = importlib.util.spec_from_file_location(
"custom_components.houseplan.junction_limits", _PATH,
# Каноническое имя обязательно: модуль импортирует зеркало миграции
# относительным путём (`from .wall_segment_model import ...`), а это резолвится
# только когда модуль знает свой пакет. Подмена родительских пакетов живёт
# ровно на время загрузки — почему именно так, см. tests_backend/pure_imports.py
# и #394.
jl = load_pure(
"custom_components.houseplan.junction_limits",
Path(_HOUSEPLAN_ROOT) / "junction_limits.py",
)
jl = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = jl
_spec.loader.exec_module(jl)
CELL = 5.0
+10 -18
View File
@@ -10,28 +10,20 @@ import types
import pytest
import voluptuous as vol
from pathlib import Path
from pure_imports import load_pure
_ROOT = os.path.dirname(os.path.dirname(__file__))
_PACKAGE_ROOT = os.path.join(_ROOT, "custom_components")
_HOUSEPLAN_ROOT = os.path.join(_PACKAGE_ROOT, "houseplan")
# Keep this pure test independent of Home Assistant even though Python normally
# executes package __init__.py before resolving the validation submodule.
if "custom_components" not in sys.modules:
package = types.ModuleType("custom_components")
package.__path__ = [_PACKAGE_ROOT]
sys.modules["custom_components"] = package
if "custom_components.houseplan" not in sys.modules:
package = types.ModuleType("custom_components.houseplan")
package.__path__ = [_HOUSEPLAN_ROOT]
sys.modules["custom_components.houseplan"] = package
_PATH = os.path.join(
_ROOT,
"custom_components", "houseplan", "validation.py",
)
_spec = importlib.util.spec_from_file_location("hp_validation", _PATH)
v = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(v)
# Тест обходится без Home Assistant, хотя Python выполнил бы __init__.py пакета
# прежде, чем добрался до validation. Подмена родительских пакетов живёт ровно
# на время загрузки: прежняя редакция ставила пустышки насовсем и не падала
# только потому, что настоящий пакет успевал импортироваться из файла, который
# идёт раньше по алфавиту (#394). См. tests_backend/pure_imports.py.
v = load_pure("hp_validation", Path(_HOUSEPLAN_ROOT) / "validation.py")
def test_backend_model_version_matches_frontend_constant():