From 3d102c4df334eac227b3ad0ed2714bcb6b556334 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 30 Sep 2026 19:33:44 +0200 Subject: [PATCH] Implement a classlevel ReadOnlyDict instead of a dict per instance for allowed_context_keys in Selector classes (#183761) --- homeassistant/helpers/selector.py | 45 ++++++++++++++++------------ tests/helpers/test_selector.py | 49 ++++++++++++++++++------------- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index d4abf7faf497..25c1ae491e86 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -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.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 9fb345ce2141..7296ddb61b73 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -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(