[config_validation] Add a visibility UI-hint kwarg (#16267)

This commit is contained in:
J. Nick Koston
2026-05-10 19:07:15 -05:00
committed by GitHub
parent 3c042e2e44
commit 930d539969
4 changed files with 313 additions and 7 deletions
+8 -1
View File
@@ -345,7 +345,14 @@ TIME_SCHEMA = cv.Schema(
}
),
}
).extend(cv.polling_component_schema("15min"))
).extend(
# ``visibility=ADVANCED`` flags the inherited ``update_interval``
# field for visual editors — the 15min default is correct for
# essentially every user, so editors should keep it tucked under
# "advanced" so it doesn't crowd the form. Validation is
# unaffected; YAML can override as before.
cv.polling_component_schema("15min", visibility=cv.Visibility.ADVANCED)
)
def _emit_dst_rule_fields(prefix, rule):
+109 -6
View File
@@ -89,6 +89,7 @@ from esphome.core import (
TimePeriodNanoseconds,
TimePeriodSeconds,
)
from esphome.enum import StrEnum
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
from esphome.helpers import add_class_to_obj, docs_url, list_starts_with
from esphome.schema_extractors import (
@@ -281,6 +282,54 @@ RESERVED_IDS = [
]
class Visibility(StrEnum):
"""Schema-driven UI hint for visual editors.
The values describe how a schema-aware editor (e.g. the
device-builder dashboard catalog via
``script/build_language_schema.py``) should render the field.
They do NOT affect validation — the YAML still accepts the key
the same way. ESPHome itself ignores the value at runtime;
consumers downstream of the schema dump act on it.
A field with no ``visibility`` set (the default) renders on the
editor's main form. The two values below are points along a
single axis of "how prominently to surface this":
- ``ADVANCED`` — render under the editor's "advanced settings"
disclosure. Use for fields whose default is right for ~all
users (e.g. ``update_interval`` on time platforms — 15 min is
universally correct, but power users can still tune the YAML
directly).
- ``YAML_ONLY`` — never render in a visual editor. Use for
knobs that are dangerous to expose in a UI even as advanced
(``setup_priority`` is the canonical example — casual UI
tweaks can break boot). The YAML escape hatch stays
available for the rare power-user override.
The single-axis shape encodes "yaml-only is strictly stronger
than advanced" at the type level — there's no way to ask for
both at once, and no way to set a contradictory state like
"advanced=False, yaml_only=True".
Per-field; the dumper walks recursively into nested schemas
and emits each field's setting independently. Cascading
semantics — "a stricter parent makes its descendants at-least
as strict" — belong on the consumer side: the schema marker
is faithfully what the field author wrote, and a consumer that
cares about effective visibility walks the parent chain and
takes the strictest setting. ``YAML_ONLY`` is strictly stronger
than ``ADVANCED``, which is strictly stronger than no setting.
Inner fields can declare their own visibility; an inner
``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``,
and the consumer's cascade keeps siblings under the parent at
``ADVANCED`` regardless of their own (less-strict) setting.
"""
ADVANCED = "advanced"
YAML_ONLY = "yaml_only"
class Optional(vol.Optional):
"""Mark a field as optional and optionally define a default for the field.
@@ -295,22 +344,45 @@ class Optional(vol.Optional):
In ESPHome, all configuration defaults should be defined with the Optional class
during config validation - specifically *not* in the C++ code or the code generation
phase.
See :class:`Visibility` for the ``visibility`` kwarg — a UI
hint for schema-driven editors that doesn't affect validation.
"""
def __init__(self, key, default=UNDEFINED):
def __init__(
self,
key,
default=UNDEFINED,
*,
visibility: Visibility | None = None,
):
super().__init__(key, default=default)
self.visibility: Visibility | None = visibility
class Required(vol.Required):
"""Define a field to be required to be set. The validated configuration is guaranteed
to contain this key.
All required values should be acceessed with the `config[CONF_<KEY>]` syntax in code
All required values should be accessed with the `config[CONF_<KEY>]` syntax in code
- *not* the `config.get(CONF_<KEY>)` syntax.
See :class:`Visibility` for the ``visibility`` kwarg — a UI
hint for schema-driven editors that doesn't affect validation.
Required fields rarely need it (a required field by definition
needs the user's attention) but the kwarg is exposed for
symmetry so consumers can apply uniform logic across key markers.
"""
def __init__(self, key, msg=None):
def __init__(
self,
key,
msg=None,
*,
visibility: Visibility | None = None,
):
super().__init__(key, msg=msg)
self.visibility: Visibility | None = visibility
class FinalExternalInvalid(Invalid):
@@ -2162,16 +2234,45 @@ ENTITY_BASE_SCHEMA = Schema(
ENTITY_BASE_SCHEMA.add_extra(_entity_base_validator)
COMPONENT_SCHEMA = Schema({Optional(CONF_SETUP_PRIORITY): float_})
COMPONENT_SCHEMA = Schema(
{
# ``setup_priority`` controls the relative order in which
# components are brought up at boot. Wrong values can break
# the boot sequence in subtle ways (e.g. an i2c device set
# to higher priority than the bus). Mark it ``YAML_ONLY`` so
# visual editors never render it — the YAML escape hatch
# stays available for the rare component author who really
# needs to override the default.
Optional(CONF_SETUP_PRIORITY, visibility=Visibility.YAML_ONLY): float_,
}
)
def polling_component_schema(default_update_interval):
def polling_component_schema(
default_update_interval, *, visibility: Visibility | None = None
):
"""Validate that this component represents a PollingComponent with a configurable
update_interval.
:param default_update_interval: The default update interval to set for the integration.
:param visibility: When set, propagate to the inherited
``update_interval`` field's :class:`Visibility` UI hint. Set
this for components whose default cadence is already correct
for ~all users (e.g. time platforms — pass
``Visibility.ADVANCED``).
Only honoured on the optional-default branch. When
``default_update_interval`` is ``None`` the field becomes
``Required`` (the component has no sensible default cadence and
needs the user to choose), and hiding a Required field behind
an advanced disclosure would be a UX hazard — collapsed-by-default
editors could let the user submit without realising the form has
an unfilled required field. The kwarg is silently ignored on that
path so callers can pass it unconditionally.
"""
if default_update_interval is None:
# Required → don't honour ``visibility``.
# See the docstring for the UX rationale.
return COMPONENT_SCHEMA.extend(
{
Required(CONF_UPDATE_INTERVAL): update_interval,
@@ -2181,7 +2282,9 @@ def polling_component_schema(default_update_interval):
return COMPONENT_SCHEMA.extend(
{
Optional(
CONF_UPDATE_INTERVAL, default=default_update_interval
CONF_UPDATE_INTERVAL,
default=default_update_interval,
visibility=visibility,
): update_interval,
}
)
+12
View File
@@ -1103,6 +1103,18 @@ def convert_keys(converted, schema, path):
if default_value is not None:
result["default"] = str(default_value)
# UI hint from ``cv.Optional`` / ``cv.Required`` — surfaced
# for schema consumers (visual editors) that want to render
# advanced / yaml-only fields differently. ESPHome itself
# ignores it at runtime; emitting only when set keeps the
# dump compact and backwards-compatible with markers that
# don't carry the attribute. The value is the str form of
# ``cv.Visibility`` (e.g. ``"advanced"`` / ``"yaml_only"``)
# so consumers don't need an enum import to read it.
visibility = getattr(k, "visibility", None)
if visibility is not None:
result["visibility"] = str(visibility)
# Do value
convert(v, result, path + f"/{str(k)}")
if "schema" not in converted:
+184
View File
@@ -793,3 +793,187 @@ def test_update_interval__never_passes_through() -> None:
"""update_interval: never must still map to SCHEDULER_DONT_RUN."""
result = config_validation.update_interval("never")
assert result.total_milliseconds == SCHEDULER_DONT_RUN
# ---------------------------------------------------------------------------
# Visibility UI-hint kwarg
# ---------------------------------------------------------------------------
def test_optional_default_visibility_is_none() -> None:
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``.
Consumers can read the attribute directly with plain attribute
access; absence (``None``) means "render on the editor's main
form."
"""
o = config_validation.Optional("foo")
assert o.visibility is None
def test_optional_visibility_advanced() -> None:
"""``visibility=Visibility.ADVANCED`` is recorded on the marker."""
o = config_validation.Optional(
"foo", visibility=config_validation.Visibility.ADVANCED
)
assert o.visibility is config_validation.Visibility.ADVANCED
def test_optional_visibility_yaml_only() -> None:
"""``visibility=Visibility.YAML_ONLY`` is recorded on the marker."""
o = config_validation.Optional(
"foo", visibility=config_validation.Visibility.YAML_ONLY
)
assert o.visibility is config_validation.Visibility.YAML_ONLY
def test_visibility_str_values_match_dump_emission() -> None:
"""``Visibility`` is a ``StrEnum`` whose values are the literal
strings the schema dumper emits.
The schema bundle consumers (catalog generators, third-party
schema-aware tooling) shouldn't need an enum import to read the
field — pinning the on-the-wire spelling here keeps the dump
contract stable.
"""
assert str(config_validation.Visibility.ADVANCED) == "advanced"
assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only"
def test_optional_visibility_does_not_affect_validation() -> None:
"""The kwarg is an advisory UI hint — it must not change how the
validator behaves. A schema with ``visibility`` applied must
accept and reject the same values it would without it.
"""
plain = config_validation.Schema(
{config_validation.Optional("foo", default=42): config_validation.int_}
)
flagged = config_validation.Schema(
{
config_validation.Optional(
"foo",
default=42,
visibility=config_validation.Visibility.YAML_ONLY,
): config_validation.int_
}
)
# Same accept / default-fill behavior.
assert plain({"foo": 7}) == flagged({"foo": 7}) == {"foo": 7}
assert plain({}) == flagged({}) == {"foo": 42}
# Same rejection on bad input.
with pytest.raises(Invalid):
plain({"foo": "not-an-int"})
with pytest.raises(Invalid):
flagged({"foo": "not-an-int"})
def test_required_default_visibility_is_none() -> None:
"""``Required`` mirrors ``Optional`` for the ``visibility`` kwarg."""
r = config_validation.Required("foo")
assert r.visibility is None
def test_required_visibility_kwarg() -> None:
"""``Required`` accepts ``visibility`` for symmetry with ``Optional``.
Required fields rarely need the kwarg, but exposing it lets
consumers apply uniform logic across key markers.
"""
r = config_validation.Required(
"foo", visibility=config_validation.Visibility.ADVANCED
)
assert r.visibility is config_validation.Visibility.ADVANCED
def test_polling_component_schema_visibility_opt_in() -> None:
"""``visibility=`` propagates to the inherited ``update_interval``.
Time platforms pass ``Visibility.ADVANCED``; sensors and other
polling components leave it ``None`` and keep the un-flagged shape.
"""
default = config_validation.polling_component_schema("15min")
advanced = config_validation.polling_component_schema(
"15min", visibility=config_validation.Visibility.ADVANCED
)
default_keys = {str(k): k for k in default.schema}
advanced_keys = {str(k): k for k in advanced.schema}
assert default_keys["update_interval"].visibility is None
assert (
advanced_keys["update_interval"].visibility
is config_validation.Visibility.ADVANCED
)
# The opt-in only touches update_interval — setup_priority
# still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA
# in both shapes.
assert (
default_keys["setup_priority"].visibility
is config_validation.Visibility.YAML_ONLY
)
assert (
advanced_keys["setup_priority"].visibility
is config_validation.Visibility.YAML_ONLY
)
def test_polling_component_schema_no_default_ignores_visibility() -> None:
"""``visibility`` is silently ignored when the field is Required.
When ``default_update_interval=None`` the field becomes
``Required``. Hiding a Required field behind an advanced
disclosure is a UX hazard — a collapsed-by-default editor could
let the user submit without noticing the form has an unfilled
required field. The helper accepts the kwarg unconditionally
for caller ergonomics but doesn't honour it on this branch.
"""
schema = config_validation.polling_component_schema(
None, visibility=config_validation.Visibility.ADVANCED
)
keys = {str(k): k for k in schema.schema}
assert isinstance(keys["update_interval"], config_validation.Required)
assert keys["update_interval"].visibility is None
def test_visibility_marker_is_per_field_no_mutation() -> None:
"""Each field's ``visibility`` is recorded as the author wrote it.
Cascading semantics — "a stricter parent forces its descendants
at-least as strict" — live on the consumer side, not in the
marker itself. The schema marker stays as-written so consumers
can walk the parent chain and compute the effective visibility
themselves; mutating the marker would lose the per-field author
intent.
Pin both directions of the no-mutation contract: an inner
``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``
on the marker (the consumer's effective-visibility cascade
would also report ``YAML_ONLY`` since it's stricter), and an
un-marked inner field stays ``None`` on the marker (the
cascade's job is to compute ``ADVANCED`` from the parent — a
detail this test deliberately doesn't pin, since it's a
consumer concern).
"""
inner_unset = config_validation.Optional("baz")
inner_yaml_only = config_validation.Optional(
"qux", visibility=config_validation.Visibility.YAML_ONLY
)
parent = config_validation.Optional(
"foo", visibility=config_validation.Visibility.ADVANCED
)
# Wire them into a nested schema — none of the markers' own
# ``visibility`` should change as a result.
schema = config_validation.Schema(
{
parent: config_validation.Schema(
{
inner_unset: config_validation.int_,
inner_yaml_only: config_validation.string,
}
)
}
)
assert schema # touch the schema so any deferred mutation runs
assert parent.visibility is config_validation.Visibility.ADVANCED
assert inner_unset.visibility is None
assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY