From 8d988723cdcd18dbe5a464f3065017a3cf64cbcf Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:38:50 -0400 Subject: [PATCH] [config] Allow !extend/!remove on components without id in schema (#14682) Co-authored-by: Claude Opus 4.6 --- esphome/voluptuous_schema.py | 6 ++ .../external_components/common.yaml | 6 +- .../external_components/test.esp32-idf.yaml | 4 + tests/unit_tests/test_voluptuous_schema.py | 86 +++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_voluptuous_schema.py diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 7220fb307f..0703c54a7a 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -175,6 +175,12 @@ class _Schema(vol.Schema): else: if self.extra == vol.ALLOW_EXTRA: out[key] = value + elif key == "id": + # Silently drop 'id' on any dict so that + # !extend / !remove work on every list-based + # config without requiring each component to + # declare an id in its schema. + pass elif self.extra != vol.REMOVE_EXTRA: if isinstance(key, str) and key_names: matches = difflib.get_close_matches(key, key_names) diff --git a/tests/components/external_components/common.yaml b/tests/components/external_components/common.yaml index 2b51267ec6..c55129094c 100644 --- a/tests/components/external_components/common.yaml +++ b/tests/components/external_components/common.yaml @@ -1,6 +1,8 @@ external_components: - - source: github://esphome/esphome@dev + - id: my_ext + source: github://esphome/esphome@dev refresh: 1d components: [bh1750] - - source: ../../../esphome/components + - id: my_local + source: ../../../esphome/components components: [sntp] diff --git a/tests/components/external_components/test.esp32-idf.yaml b/tests/components/external_components/test.esp32-idf.yaml index dade44d145..afe2bd5d6a 100644 --- a/tests/components/external_components/test.esp32-idf.yaml +++ b/tests/components/external_components/test.esp32-idf.yaml @@ -1 +1,5 @@ +# WARNING: Using !extend or !remove prevents automatic component grouping in CI, making builds slower. <<: !include common.yaml + +external_components: + - id: !remove my_local diff --git a/tests/unit_tests/test_voluptuous_schema.py b/tests/unit_tests/test_voluptuous_schema.py new file mode 100644 index 0000000000..21c7decede --- /dev/null +++ b/tests/unit_tests/test_voluptuous_schema.py @@ -0,0 +1,86 @@ +"""Tests for voluptuous_schema.py.""" + +import pytest +import voluptuous as vol + +from esphome.voluptuous_schema import _Schema + + +class TestIdKeyDropping: + """Test that 'id' keys are silently dropped in PREVENT_EXTRA schemas.""" + + def test_id_key_silently_dropped(self): + """Schema without 'id' should accept and drop 'id' key from input.""" + schema = _Schema( + { + vol.Required("name"): str, + vol.Optional("value", default=0): int, + } + ) + result = schema({"name": "test", "value": 42, "id": "my_id"}) + assert result == {"name": "test", "value": 42} + assert "id" not in result + + def test_id_key_dropped_with_only_required(self): + """Schema with only required keys should still drop 'id'.""" + schema = _Schema( + { + vol.Required("source"): str, + } + ) + result = schema({"source": "github://test", "id": "my_component"}) + assert result == {"source": "github://test"} + + def test_other_extra_keys_still_rejected(self): + """Non-'id' extra keys should still raise errors.""" + schema = _Schema( + { + vol.Required("name"): str, + } + ) + with pytest.raises(vol.MultipleInvalid, match="extra keys not allowed"): + schema({"name": "test", "unknown_key": "value"}) + + def test_id_key_not_dropped_when_in_schema(self): + """When 'id' is declared in the schema, it should be validated normally.""" + schema = _Schema( + { + vol.Required("id"): str, + vol.Required("name"): str, + } + ) + result = schema({"id": "my_id", "name": "test"}) + assert result == {"id": "my_id", "name": "test"} + + def test_id_key_not_dropped_with_allow_extra(self): + """With ALLOW_EXTRA, 'id' should be kept (not dropped).""" + schema = _Schema( + { + vol.Required("name"): str, + }, + extra=vol.ALLOW_EXTRA, + ) + result = schema({"name": "test", "id": "my_id"}) + assert result == {"name": "test", "id": "my_id"} + + def test_id_key_dropped_with_remove_extra(self): + """With REMOVE_EXTRA, 'id' should be removed along with other extras.""" + schema = _Schema( + { + vol.Required("name"): str, + }, + extra=vol.REMOVE_EXTRA, + ) + result = schema({"name": "test", "id": "my_id", "other": "value"}) + assert result == {"name": "test"} + + def test_without_id_no_extra_keys(self): + """Normal validation without 'id' key should work as before.""" + schema = _Schema( + { + vol.Required("name"): str, + vol.Optional("value", default=0): int, + } + ) + result = schema({"name": "test"}) + assert result == {"name": "test", "value": 0}