Implement a classlevel ReadOnlyDict instead of a dict per instance for allowed_context_keys in Selector classes (#183761)

This commit is contained in:
Jan Bouwhuis
2026-09-30 19:33:44 +02:00
committed by GitHub
parent 5ee5ec24b3
commit 3d102c4df3
2 changed files with 56 additions and 38 deletions
+27 -18
View File
@@ -14,6 +14,7 @@ from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT, Platform
from homeassistant.core import split_entity_id, valid_entity_id
from homeassistant.generated.countries import COUNTRIES
from homeassistant.util import decorator
from homeassistant.util.read_only_dict import ReadOnlyDict
from homeassistant.util.yaml import dumper
from . import config_validation as cv
@@ -66,12 +67,11 @@ class Selector[_T: Mapping[str, Any]]:
# context for filtering for example. The selector defines
# which context keys it supports and what selector types
# are allowed for each key.
allowed_context_keys: dict[str, set[str]]
allowed_context_keys: Mapping[str, frozenset[str]] = ReadOnlyDict({})
def __init__(self, config: Mapping[str, Any] | None = None) -> None:
"""Instantiate a selector."""
self.config = self.CONFIG_SCHEMA(config)
self.allowed_context_keys = {}
@override
def __eq__(self, other: object) -> bool:
@@ -441,6 +441,13 @@ class AttributeSelector(Selector[AttributeSelectorConfig]):
selector_type = "attribute"
allowed_context_keys = ReadOnlyDict(
{
# Filters the available attributes based on the selected entity
"filter_entity": frozenset({"entity"})
}
)
CONFIG_SCHEMA = make_selector_config_schema(
{
probatio.Required("entity_id"): cv.entity_id,
@@ -453,10 +460,6 @@ class AttributeSelector(Selector[AttributeSelectorConfig]):
def __init__(self, config: AttributeSelectorConfig) -> None:
"""Instantiate a selector."""
super().__init__(config)
self.allowed_context_keys = {
# Filters the available attributes based on the selected entity
"filter_entity": {"entity"}
}
def __call__(self, data: Any) -> str:
"""Validate the passed selection."""
@@ -1374,6 +1377,13 @@ class MediaSelector(Selector[MediaSelectorConfig]):
selector_type = "media"
allowed_context_keys = ReadOnlyDict(
{
# Filters the available media based on the selected entity
"filter_entity": frozenset({EntitySelector.selector_type})
}
)
CONFIG_SCHEMA = probatio.All(
make_selector_config_schema(
{
@@ -1400,10 +1410,6 @@ class MediaSelector(Selector[MediaSelectorConfig]):
def __init__(self, config: MediaSelectorConfig | None = None) -> None:
"""Instantiate a selector."""
super().__init__(config)
self.allowed_context_keys = {
# Filters the available media based on the selected entity
"filter_entity": {EntitySelector.selector_type}
}
def __call__(self, data: Any) -> dict[str, Any] | list[dict[str, Any]]:
"""Validate the passed selection."""
@@ -2071,6 +2077,17 @@ class StateSelector(Selector[StateSelectorConfig]):
selector_type = "state"
allowed_context_keys = ReadOnlyDict(
{
# Filters the available states based on the selected entity
"filter_entity": frozenset({EntitySelector.selector_type}),
# Filters the available states based on the selected target
"filter_target": frozenset({"target"}),
# Only show the attribute values of a specific attribute
"filter_attribute": frozenset({AttributeSelector.selector_type}),
}
)
CONFIG_SCHEMA = make_selector_config_schema(
{
probatio.Optional("entity_id"): cv.entity_id,
@@ -2083,14 +2100,6 @@ class StateSelector(Selector[StateSelectorConfig]):
def __init__(self, config: StateSelectorConfig) -> None:
"""Instantiate a selector."""
super().__init__(config)
self.allowed_context_keys = {
# Filters the available states based on the selected entity
"filter_entity": {EntitySelector.selector_type},
# Filters the available states based on the selected target
"filter_target": {"target"},
# Only show the attribute values of a specific attribute
"filter_attribute": {AttributeSelector.selector_type},
}
def __call__(self, data: Any) -> str | list[str]:
"""Validate the passed selection."""
+29 -20
View File
@@ -44,28 +44,37 @@ def test_invalid_base_schema(schema) -> None:
selector.validate_selector(schema)
def test_allowed_context_keys_not_shared_between_instances() -> None:
"""Test allowed_context_keys is isolated between selector instances."""
@pytest.mark.parametrize(
"selector_class",
[
pytest.param(selector.Selector, id="base"),
pytest.param(selector.AttributeSelector, id="attribute"),
pytest.param(selector.MediaSelector, id="media"),
pytest.param(selector.StateSelector, id="state"),
],
)
def test_allowed_context_keys_read_only(
selector_class: type[selector.Selector],
) -> None:
"""Test allowed_context_keys cannot be modified."""
with pytest.raises(RuntimeError, match="Cannot modify ReadOnlyDict"):
selector_class.allowed_context_keys["some_key"] = frozenset()
class TestSelectorConfig(selector.BaseSelectorConfig, total=False):
"""Test selector config class."""
class TestSelector(selector.Selector):
"""Test selector used to verify instance isolation."""
CONFIG_SCHEMA = selector.make_selector_config_schema({})
selector_type = "test"
def __call__(self, data: Any) -> Any:
"""Validate the passed selection."""
return data
test_selector = TestSelector(TestSelectorConfig())
other_selector = TestSelector(TestSelectorConfig())
test_selector.allowed_context_keys["some_key"] = set()
assert test_selector.allowed_context_keys
assert not other_selector.allowed_context_keys
@pytest.mark.parametrize(
"selector_class",
[
pytest.param(selector.AttributeSelector, id="attribute"),
pytest.param(selector.MediaSelector, id="media"),
pytest.param(selector.StateSelector, id="state"),
],
)
def test_allowed_context_keys_values_immutable(
selector_class: type[selector.Selector],
) -> None:
"""Test allowed_context_keys values cannot be modified."""
for allowed_types in selector_class.allowed_context_keys.values():
assert isinstance(allowed_types, frozenset)
def _test_selector(