mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 11:07:33 +00:00
[lvgl] Fix schema extraction (#16895)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.final_validate import full_config
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.writer import clean_build
|
||||
from esphome.yaml_util import load_yaml
|
||||
|
||||
@@ -75,10 +76,14 @@ from .schemas import (
|
||||
BASE_PROPS,
|
||||
DISP_BG_SCHEMA,
|
||||
FULL_STYLE_SCHEMA,
|
||||
SET_STATE_SCHEMA,
|
||||
STATE_SCHEMA,
|
||||
STYLE_REMAP,
|
||||
STYLE_SCHEMA,
|
||||
WIDGET_TYPES,
|
||||
any_widget_schema,
|
||||
container_schema,
|
||||
container_schema_value,
|
||||
obj_dict,
|
||||
)
|
||||
from .styles import styles_to_code, theme_to_code
|
||||
@@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA
|
||||
page_spec,
|
||||
)
|
||||
|
||||
# These style schemas live in .schemas but are imported here so they land in
|
||||
# this module's namespace, where script/build_language_schema.py registers them
|
||||
# as *named* schemas and emits `extends` references — instead of inlining the
|
||||
# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the
|
||||
# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in
|
||||
# this file; this tuple keeps the imports live (and self-documents why).
|
||||
_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA)
|
||||
|
||||
# Widget registration happens via WidgetType.__init__ in individual widget files
|
||||
# The imports below trigger creation of the widget types
|
||||
# Action registration (lvgl.{widget}.update) happens automatically
|
||||
@@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict:
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validation
|
||||
|
||||
LVGL_SCHEMA = cv.All(
|
||||
container_schema(
|
||||
obj_spec,
|
||||
cv.polling_component_schema("1s")
|
||||
.extend(
|
||||
{
|
||||
**{
|
||||
cv.Optional(event): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
|
||||
),
|
||||
}
|
||||
)
|
||||
for event in df.LV_SCREEN_EVENT_TRIGGERS
|
||||
+ df.LV_DISPLAY_EVENT_TRIGGERS
|
||||
},
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
|
||||
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
|
||||
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
|
||||
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
|
||||
cv.Optional(
|
||||
df.CONF_DEFAULT_FONT, default="montserrat_14"
|
||||
): lvalid.lv_font,
|
||||
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
|
||||
cv.Optional(
|
||||
df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False
|
||||
): cv.boolean,
|
||||
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
|
||||
cv.Optional(CONF_ROTATION): validate_rotation,
|
||||
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
|
||||
*df.LV_LOG_LEVELS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
|
||||
"big_endian", "little_endian", lower=True
|
||||
),
|
||||
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
|
||||
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
|
||||
FULL_STYLE_SCHEMA
|
||||
)
|
||||
),
|
||||
cv.Optional(CONF_ON_IDLE): validate_automation(
|
||||
# The options accepted at the top level of an `lvgl:` block, on top of the base
|
||||
# object schema that `container_schema(obj_spec, ...)` supplies. Held in a
|
||||
# module-level name (rather than inline) so the schema-extractor wrapper on
|
||||
# CONFIG_SCHEMA below can hand the language-schema dumper the same composed
|
||||
# schema the runtime validates against.
|
||||
LVGL_TOP_LEVEL_SCHEMA = (
|
||||
cv.polling_component_schema("1s")
|
||||
.extend(
|
||||
{
|
||||
**{
|
||||
cv.Optional(event): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
|
||||
cv.Required(CONF_TIMEOUT): cv.templatable(
|
||||
cv.positive_time_period_milliseconds
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
|
||||
**{
|
||||
cv.Optional(x): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
|
||||
},
|
||||
single=True,
|
||||
)
|
||||
for x in SIMPLE_TRIGGERS
|
||||
},
|
||||
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
|
||||
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
|
||||
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(
|
||||
df.CONF_TRANSPARENCY_KEY, default=0x000400
|
||||
): lvalid.lv_color,
|
||||
cv.Optional(df.CONF_THEME): _theme_schema,
|
||||
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
|
||||
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
|
||||
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
|
||||
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
|
||||
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
|
||||
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(DISP_BG_SCHEMA),
|
||||
),
|
||||
)
|
||||
for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS
|
||||
},
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
|
||||
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
|
||||
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
|
||||
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
|
||||
cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font,
|
||||
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
|
||||
cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
|
||||
cv.Optional(CONF_ROTATION): validate_rotation,
|
||||
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
|
||||
*df.LV_LOG_LEVELS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
|
||||
"big_endian", "little_endian", lower=True
|
||||
),
|
||||
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
|
||||
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
|
||||
FULL_STYLE_SCHEMA
|
||||
)
|
||||
),
|
||||
cv.Optional(CONF_ON_IDLE): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
|
||||
cv.Required(CONF_TIMEOUT): cv.templatable(
|
||||
cv.positive_time_period_milliseconds
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
|
||||
**{
|
||||
cv.Optional(x): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
|
||||
},
|
||||
single=True,
|
||||
)
|
||||
for x in SIMPLE_TRIGGERS
|
||||
},
|
||||
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
|
||||
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
|
||||
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color,
|
||||
cv.Optional(df.CONF_THEME): _theme_schema,
|
||||
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
|
||||
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
|
||||
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
|
||||
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
|
||||
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
|
||||
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(DISP_BG_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
LVGL_SCHEMA = cv.All(
|
||||
container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA),
|
||||
cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT),
|
||||
add_hello_world,
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def lvgl_config_schema(config):
|
||||
"""
|
||||
Can't use cv.ensure_list here because it converts an empty config to an empty list,
|
||||
rather than a default config.
|
||||
"""
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema
|
||||
# closure, so the language-schema dumper can't see the top-level `lvgl:`
|
||||
# fields (it would emit an empty schema). Hand it the same composed
|
||||
# obj + top-level schema the runtime validates against, plus the
|
||||
# `widgets:` key (added per-value by append_layout_schema at runtime, so
|
||||
# otherwise invisible to the dumper). Validation of real configs (the
|
||||
# branches below) is unchanged.
|
||||
return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend(
|
||||
{cv.Optional(df.CONF_WIDGETS): any_widget_schema()}
|
||||
)
|
||||
if not config or isinstance(config, dict):
|
||||
return [LVGL_SCHEMA(config)]
|
||||
return cv.Schema([LVGL_SCHEMA])(config)
|
||||
|
||||
@@ -22,7 +22,11 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import TimePeriod
|
||||
from esphome.core.config import StartupTrigger
|
||||
from esphome.schema_extractors import EnableSchemaExtraction
|
||||
from esphome.schema_extractors import (
|
||||
SCHEMA_EXTRACT,
|
||||
EnableSchemaExtraction,
|
||||
schema_extractor,
|
||||
)
|
||||
|
||||
from . import defines as df, lv_validation as lvalid
|
||||
from .defines import (
|
||||
@@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[
|
||||
] = {}
|
||||
|
||||
|
||||
def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema:
|
||||
"""
|
||||
Build the static schema that :func:`container_schema` validates against, i.e.
|
||||
everything except the value-dependent ``append_layout_schema`` applied at
|
||||
validation time.
|
||||
|
||||
Factored out and exposed so the language-schema dumper can extract a
|
||||
representative schema for a widget — and for the top-level ``lvgl:`` block,
|
||||
whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the
|
||||
:func:`container_schema` validator closure.
|
||||
"""
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
return schema.extend(widget_type.schema)
|
||||
|
||||
|
||||
def container_schema(
|
||||
widget_type: WidgetType, extras: Any = None
|
||||
) -> Callable[[Any], Any]:
|
||||
@@ -649,12 +672,7 @@ def container_schema(
|
||||
def get_schema() -> cv.Schema:
|
||||
nonlocal cached_schema
|
||||
if cached_schema is None:
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
cached_schema = schema.extend(widget_type.schema)
|
||||
cached_schema = container_schema_value(widget_type, extras)
|
||||
return cached_schema
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
@@ -678,7 +696,23 @@ def any_widget_schema(extras=None):
|
||||
:return: A validator for the Widgets key
|
||||
"""
|
||||
|
||||
@schema_extractor("schema")
|
||||
def validator(value):
|
||||
if value is SCHEMA_EXTRACT:
|
||||
# The widgets: list is built per-value at validation time, so the
|
||||
# language-schema dumper sees nothing. Enumerate every registered
|
||||
# widget type as an optional key (a widget item is really a
|
||||
# single-key mapping; over-listing them lets editors complete any
|
||||
# widget — `esphome config` enforces exactly one). extras carries the
|
||||
# layout child options where applicable.
|
||||
return cv.ensure_list(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(name): container_schema_value(widget_type, extras)
|
||||
for name, widget_type in WIDGET_TYPES.items()
|
||||
}
|
||||
)
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
# Convert to list
|
||||
is_dict = True
|
||||
|
||||
@@ -428,6 +428,33 @@ def fix_menu():
|
||||
menu[S_EXTENDS].append("display_menu_base.MENU_TYPES")
|
||||
|
||||
|
||||
def fix_lvgl_widgets():
|
||||
# lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The
|
||||
# dumper has no cycle detection, so — like fix_menu — hoist the inlined
|
||||
# widget-type enumeration into a named schema and reference it for both the
|
||||
# top-level list and each widget's own children, instead of expanding it.
|
||||
if "lvgl" not in output:
|
||||
return
|
||||
schemas = output["lvgl"][S_SCHEMAS]
|
||||
config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS]
|
||||
widgets = config_vars.get("widgets")
|
||||
if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]:
|
||||
return
|
||||
# 1. Hoist the (one-level) widget enumeration into a named schema.
|
||||
schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]}
|
||||
# 2. Reference it from the top-level widgets: list instead of inlining.
|
||||
widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}
|
||||
# 3. Let every widget contain child widgets, via the same named ref.
|
||||
for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values():
|
||||
if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget:
|
||||
widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = {
|
||||
S_TYPE: S_SCHEMA,
|
||||
"is_list": True,
|
||||
"key": "Optional",
|
||||
S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]},
|
||||
}
|
||||
|
||||
|
||||
def get_logger_tags():
|
||||
pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE)
|
||||
# tags not in components dir
|
||||
@@ -740,6 +767,7 @@ def build_schema():
|
||||
add_logger_tags()
|
||||
shrink()
|
||||
fix_menu()
|
||||
fix_lvgl_widgets()
|
||||
|
||||
# aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc.
|
||||
data = {}
|
||||
|
||||
@@ -4,7 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
|
||||
@@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None:
|
||||
entry = converted["schema"]["config_vars"]["hostname"]
|
||||
assert "sensitive" not in entry
|
||||
assert "sensitive_source" not in entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression tests for the lvgl schema dump.
|
||||
#
|
||||
# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are
|
||||
# built lazily at validation time, so the static dumper used to emit an empty
|
||||
# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA
|
||||
# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise
|
||||
# the full `build_schema()` and assert the generated lvgl.json carries the data
|
||||
# the schema_extractor hooks added.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
"""Run the full language-schema build once and return parsed lvgl.json.
|
||||
|
||||
The build must run in a fresh interpreter: ``build_language_schema.py``
|
||||
enables schema extraction *before* importing any esphome component, and the
|
||||
extraction hooks are no-ops if the components were already imported (as they
|
||||
are inside the pytest session). Running it as a subprocess mirrors how CI
|
||||
generates the schema and keeps this test isolated from import order.
|
||||
"""
|
||||
out_dir = tmp_path_factory.mktemp("language_schema")
|
||||
subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads((out_dir / "lvgl.json").read_text())
|
||||
|
||||
|
||||
def _lvgl_config_vars(lvgl_schema: dict) -> dict:
|
||||
config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"]
|
||||
# Previously empty (`{}`); the schema_extractor on lvgl_config_schema now
|
||||
# hands the dumper the composed top-level schema.
|
||||
assert config_schema["type"] == "schema"
|
||||
return config_schema["schema"]["config_vars"]
|
||||
|
||||
|
||||
def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None:
|
||||
config_vars = _lvgl_config_vars(lvgl_schema)
|
||||
# Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed.
|
||||
assert len(config_vars) > 100
|
||||
# A representative spread of top-level options the runtime validates.
|
||||
for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"):
|
||||
assert key in config_vars, f"missing top-level lvgl option: {key}"
|
||||
|
||||
|
||||
def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None:
|
||||
config_vars = _lvgl_config_vars(lvgl_schema)
|
||||
# The widgets: list is assembled per-value at runtime; the extractor
|
||||
# enumerates every registered widget type into a named WIDGET_TYPES schema
|
||||
# which the widgets: list references (recursive, so widgets can nest).
|
||||
assert "widgets" in config_vars
|
||||
widgets = config_vars["widgets"]
|
||||
assert widgets["is_list"] is True
|
||||
assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
|
||||
|
||||
widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][
|
||||
"config_vars"
|
||||
]
|
||||
# Every registered widget type should appear as an optional key.
|
||||
for name in ("obj", "label", "button", "slider", "switch", "arc"):
|
||||
assert name in widget_types, f"widget type not enumerated: {name}"
|
||||
# Each enumerated widget carries its own property schema, not an empty stub.
|
||||
assert widget_types["label"]["type"] == "schema"
|
||||
assert len(widget_types["label"]["schema"]["config_vars"]) > 0
|
||||
# Each widget can contain child widgets, via the same named ref — so the
|
||||
# tree is recursive and the dump stays finite.
|
||||
nested = widget_types["obj"]["schema"]["config_vars"]["widgets"]
|
||||
assert nested["is_list"] is True
|
||||
assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
|
||||
|
||||
|
||||
def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None:
|
||||
schemas = lvgl_schema["lvgl"]["schemas"]
|
||||
# Importing these into the lvgl __init__ namespace lets the dumper register
|
||||
# them as named schemas and emit `extends` refs instead of inlining them.
|
||||
for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"):
|
||||
assert name in schemas, f"style schema not registered as named: {name}"
|
||||
|
||||
# STYLE_SCHEMA must be referenced via `extends`, not inlined at every use
|
||||
# site. Count the references to prove the dedup actually happened.
|
||||
refs = 0
|
||||
|
||||
def _count(node: object) -> None:
|
||||
nonlocal refs
|
||||
if isinstance(node, dict):
|
||||
extends = node.get("extends")
|
||||
if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends:
|
||||
refs += 1
|
||||
for value in node.values():
|
||||
_count(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
_count(value)
|
||||
|
||||
_count(lvgl_schema)
|
||||
assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}"
|
||||
|
||||
Reference in New Issue
Block a user