mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Красный 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
324 lines
14 KiB
Python
324 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""#33: dump the canonical persisted-config schema into a deterministic JSON.
|
||
|
||
The Voluptuous schema in custom_components/houseplan/validation.py is the
|
||
single owner of the persisted config/layout shape. This walker turns it into
|
||
scripts/config-schema.json — the machine-readable manifest the
|
||
frontend parity test and the field registry completeness test consume.
|
||
|
||
Determinism contract: two runs on the same tree produce byte-identical
|
||
output (sorted paths, sorted keys, stable value rendering). A validator this
|
||
walker does not understand is written fail-closed as {"opaque": "<repr>"} —
|
||
the manifest stays fresh and the parity test simply does not judge that node.
|
||
|
||
The importer stubs the parent packages so custom_components/houseplan/
|
||
__init__.py (which needs homeassistant) never executes; validation.py itself
|
||
is dependency-free apart from voluptuous.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import json
|
||
import re
|
||
import sys
|
||
import types
|
||
from pathlib import Path
|
||
|
||
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__"):
|
||
return f"<function {value.__qualname__}>"
|
||
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():
|
||
"""Загрузить `validation.py` без Home Assistant — и не сломать всё остальное.
|
||
|
||
Подмена родительских пакетов пустышками нужна затем, что обычный импорт
|
||
выполнил бы `custom_components/houseplan/__init__.py`, а тот тянет весь HA.
|
||
Скрипт обязан работать и там, где HA нет вовсе.
|
||
|
||
Но у подмены есть цена, и она однажды была заплачена (#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):
|
||
"""Stable, JSON-safe rendering of a schema default."""
|
||
try:
|
||
if callable(value):
|
||
produced = value()
|
||
return {"factory": type(produced).__name__}
|
||
json.dumps(value)
|
||
return value
|
||
except (TypeError, ValueError):
|
||
return {"opaque": _safe_repr(value)}
|
||
|
||
|
||
def build_manifest():
|
||
import voluptuous as vol
|
||
|
||
validation = _load_validation()
|
||
|
||
def describe(validator): # noqa: C901 - a walker is naturally branchy
|
||
"""Describe one value validator as a JSON-able dict."""
|
||
if isinstance(validator, vol.Schema):
|
||
return describe(validator.schema) | _extra_flag(validator)
|
||
if isinstance(validator, vol.All):
|
||
merged: dict = {}
|
||
for part in validator.validators:
|
||
for key, value in describe(part).items():
|
||
if key in merged and merged[key] != value:
|
||
merged.setdefault("conflicts", []).append({key: value})
|
||
else:
|
||
merged[key] = value
|
||
return merged or {"opaque": _safe_repr(validator)}
|
||
if isinstance(validator, vol.Any):
|
||
variants = [describe(part) for part in validator.validators]
|
||
if any(v == {"type": "null"} for v in variants):
|
||
rest = [v for v in variants if v != {"type": "null"}]
|
||
if len(rest) == 1:
|
||
return rest[0] | {"nullable": True}
|
||
return {"variants": variants}
|
||
if isinstance(validator, vol.In):
|
||
return {"enum": sorted(validator.container, key=str)}
|
||
if isinstance(validator, vol.Range):
|
||
out = {}
|
||
if validator.min is not None:
|
||
out["min"] = validator.min
|
||
if validator.max is not None:
|
||
out["max"] = validator.max
|
||
return out
|
||
if isinstance(validator, vol.Length):
|
||
out = {}
|
||
if validator.min is not None:
|
||
out["minLength"] = validator.min
|
||
if validator.max is not None:
|
||
out["maxLength"] = validator.max
|
||
return out
|
||
if isinstance(validator, vol.Equal):
|
||
return {"const": validator.target}
|
||
if isinstance(validator, vol.Coerce):
|
||
return {"type": getattr(validator.type, "__name__", _safe_repr(validator.type))}
|
||
if validator is None or validator is type(None):
|
||
return {"type": "null"}
|
||
if validator is bool:
|
||
return {"type": "bool"}
|
||
if validator is int:
|
||
return {"type": "int"}
|
||
if validator is float:
|
||
return {"type": "float"}
|
||
if validator is str:
|
||
return {"type": "str"}
|
||
if validator is dict or validator is object:
|
||
return {"type": "object"}
|
||
if isinstance(validator, dict):
|
||
return {"object": True}
|
||
if isinstance(validator, (list, tuple)):
|
||
items = [describe(part) for part in validator]
|
||
return {"list": items[0] if len(items) == 1 else items}
|
||
if isinstance(validator, (str, int, float, bool)):
|
||
return {"const": validator}
|
||
if isinstance(validator, type):
|
||
return {"type": validator.__name__}
|
||
return {"opaque": _safe_repr(validator)}
|
||
|
||
def _extra_flag(schema):
|
||
if getattr(schema, "extra", None) == vol.ALLOW_EXTRA:
|
||
return {"allowExtra": True}
|
||
return {}
|
||
|
||
leaves: dict[str, dict] = {}
|
||
|
||
def _variant_tag(schema, index: int) -> str:
|
||
"""Discriminator for an Any-of-dicts variant: kind const/enum, else index."""
|
||
inner = schema.schema if isinstance(schema, vol.Schema) else schema
|
||
if isinstance(inner, dict):
|
||
for key, value in inner.items():
|
||
name = key.schema if isinstance(key, (vol.Optional, vol.Required)) else key
|
||
if name == "kind":
|
||
if isinstance(value, str):
|
||
return value
|
||
if isinstance(value, vol.In):
|
||
return "|".join(sorted(value.container, key=str))
|
||
if isinstance(value, vol.Equal):
|
||
return str(value.target)
|
||
return f"var{index}"
|
||
|
||
def _is_struct(candidate) -> bool:
|
||
return isinstance(candidate, (vol.Schema, dict)) and isinstance(
|
||
candidate.schema if isinstance(candidate, vol.Schema) else candidate, dict)
|
||
|
||
def walk(node, path: str):
|
||
if isinstance(node, vol.Any):
|
||
non_null = [p for p in node.validators if p is not None and p is not type(None)]
|
||
nullable = len(non_null) != len(node.validators)
|
||
if non_null and all(_is_struct(p) for p in non_null):
|
||
if nullable:
|
||
leaves.setdefault(path or "<root>", {})["nullable"] = True
|
||
if len(non_null) == 1:
|
||
walk(non_null[0], path)
|
||
return
|
||
for index, variant in enumerate(non_null):
|
||
walk(variant, f"{path}<{_variant_tag(variant, index)}>")
|
||
return
|
||
leaves[path or "<root>"] = describe(node)
|
||
return
|
||
if isinstance(node, vol.Schema):
|
||
entry_extra = _extra_flag(node)
|
||
if entry_extra and path:
|
||
leaves.setdefault(path or "<root>", {}).update(entry_extra)
|
||
walk(node.schema, path)
|
||
return
|
||
if isinstance(node, vol.All):
|
||
# A dict wrapped in vol.All carries semantic validators after the
|
||
# structural schema; walk the structural part only.
|
||
structural = [p for p in node.validators
|
||
if isinstance(p, (vol.Schema, dict, list, tuple))]
|
||
if structural:
|
||
for part in structural:
|
||
walk(part, path)
|
||
return
|
||
leaves[path] = describe(node)
|
||
return
|
||
if isinstance(node, dict):
|
||
for key, value in node.items():
|
||
marker = key
|
||
info: dict = {}
|
||
if isinstance(key, vol.Remove):
|
||
inner = key.schema
|
||
name = inner if isinstance(inner, str) else _safe_repr(inner)
|
||
leaves[f"{path}.{name}".lstrip(".")] = {"removed": True}
|
||
continue
|
||
if isinstance(key, (vol.Optional, vol.Required)):
|
||
info["required"] = isinstance(key, vol.Required)
|
||
default = getattr(key, "default", vol.UNDEFINED)
|
||
if default is not vol.UNDEFINED:
|
||
info["default"] = _render_default(default)
|
||
marker = key.schema
|
||
if isinstance(marker, str):
|
||
child = f"{path}.{marker}".lstrip(".")
|
||
elif marker is str:
|
||
child = f"{path}.*".lstrip(".")
|
||
else:
|
||
child = f"{path}.<{getattr(marker, '__name__', _safe_repr(marker))}>".lstrip(".")
|
||
if isinstance(value, (vol.Schema, dict)):
|
||
if info:
|
||
leaves.setdefault(child, {}).update(info)
|
||
walk(value, child)
|
||
elif isinstance(value, (list, tuple)) and len(value) == 1 \
|
||
and isinstance(value[0], (vol.Schema, dict, vol.All)):
|
||
if info:
|
||
leaves.setdefault(f"{child}[]", {}).update(info)
|
||
walk(value[0], f"{child}[]")
|
||
elif isinstance(value, vol.All) and any(
|
||
isinstance(p, (vol.Schema, dict, list, tuple))
|
||
for p in value.validators):
|
||
if info:
|
||
leaves.setdefault(child, {}).update(info)
|
||
walk(value, child)
|
||
elif isinstance(value, vol.Any) and value.validators and all(
|
||
_is_struct(p) or p is None or p is type(None)
|
||
for p in value.validators) and any(
|
||
_is_struct(p) for p in value.validators):
|
||
if info:
|
||
leaves.setdefault(child, {}).update(info)
|
||
walk(value, child)
|
||
elif isinstance(value, (list, tuple)) and len(value) == 1 \
|
||
and isinstance(value[0], vol.Any):
|
||
if info:
|
||
leaves.setdefault(f"{child}[]", {}).update(info)
|
||
walk(value[0], f"{child}[]")
|
||
else:
|
||
leaves[child] = info | describe(value)
|
||
return
|
||
if isinstance(node, (list, tuple)) and len(node) == 1:
|
||
walk(node[0], f"{path}[]")
|
||
return
|
||
leaves[path or "<root>"] = describe(node)
|
||
|
||
walk(validation.CONFIG_SCHEMA, "config")
|
||
walk(validation.LAYOUT_SCHEMA, "layout")
|
||
|
||
return {
|
||
"_comment": "#33: generated by scripts/dump-config-schema.py — do not edit by hand",
|
||
"fields": {path: dict(sorted(entry.items())) for path, entry in sorted(leaves.items())},
|
||
}
|
||
|
||
|
||
def main() -> int:
|
||
manifest = build_manifest()
|
||
rendered = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||
if "--check" in sys.argv:
|
||
current = OUT.read_text(encoding="utf-8") if OUT.exists() else ""
|
||
if current != rendered:
|
||
sys.stderr.write(
|
||
"config-schema.json is stale: run python3 scripts/dump-config-schema.py\n")
|
||
return 1
|
||
print(f"manifest fresh: {len(manifest['fields'])} paths")
|
||
return 0
|
||
OUT.write_text(rendered, encoding="utf-8")
|
||
print(f"manifest written: {len(manifest['fields'])} paths")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|