From 76111bd7993dc4d721f924f4dde8abde63f1888a Mon Sep 17 00:00:00 2001 From: Linkplay2020 <65423368+Linkplay2020@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:12:11 +0800 Subject: [PATCH 01/15] Add reconfiguration flow to WiiM (#177444) Co-authored-by: Tao Jiang Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/wiim/config_flow.py | 33 +++++++ .../components/wiim/quality_scale.yaml | 2 +- homeassistant/components/wiim/strings.json | 12 ++- tests/components/wiim/test_config_flow.py | 97 +++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wiim/config_flow.py b/homeassistant/components/wiim/config_flow.py index 3cd60ea21b64c7..19f8131f60444e 100644 --- a/homeassistant/components/wiim/config_flow.py +++ b/homeassistant/components/wiim/config_flow.py @@ -78,6 +78,39 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user initiated reconfiguration.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + device_info = await _async_probe_wiim_host( + self.hass, user_input[CONF_HOST] + ) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device_info.udn) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_HOST: device_info.host}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + user_input + or { + CONF_HOST: self._get_reconfigure_entry().data[CONF_HOST], + }, + ), + errors=errors, + ) + @override async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo diff --git a/homeassistant/components/wiim/quality_scale.yaml b/homeassistant/components/wiim/quality_scale.yaml index 8957974f23bcc4..8edc6d9996495a 100644 --- a/homeassistant/components/wiim/quality_scale.yaml +++ b/homeassistant/components/wiim/quality_scale.yaml @@ -72,7 +72,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No known use cases for repair issues or flows, yet diff --git a/homeassistant/components/wiim/strings.json b/homeassistant/components/wiim/strings.json index fa4ff85436fafb..4a54176f1eca46 100644 --- a/homeassistant/components/wiim/strings.json +++ b/homeassistant/components/wiim/strings.json @@ -4,7 +4,9 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "missing_homeassistant_url": "Failed to determine Home Assistant URL. Please make sure you have an internal URL set." + "missing_homeassistant_url": "Failed to determine Home Assistant URL. Please make sure you have an internal URL set.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The host belongs to a different WiiM device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +17,14 @@ "discovery_confirm": { "description": "Do you want to set up {name}?" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the WiiM device." + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" diff --git a/tests/components/wiim/test_config_flow.py b/tests/components/wiim/test_config_flow.py index 0ddf67c7c3c668..62a96f68f83c73 100644 --- a/tests/components/wiim/test_config_flow.py +++ b/tests/components/wiim/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest +from wiim.models import WiimProbeResult from homeassistant.components.wiim.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF @@ -232,3 +233,99 @@ async def test_zeroconf_flow_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "192.168.1.101" + + +async def test_reconfigure_flow_updates_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_probe_player: AsyncMock, +) -> None: + """Test reconfigure updates the host for the same WiiM device.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_probe_player.return_value = WiimProbeResult( + host="192.168.1.111", + udn="uuid:test-udn-1234", + name="WiiM Pro", + location="http://192.168.1.111:49152/description.xml", + model="WiiM Pro", + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.111"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.111" + assert mock_config_entry.unique_id == "uuid:test-udn-1234" + assert hass.config_entries.async_entries(DOMAIN) == [mock_config_entry] + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_probe_player: AsyncMock, +) -> None: + """Test reconfigure keeps the form open when the new host cannot connect.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + mock_probe_player.side_effect = TimeoutError + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.111"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + assert mock_config_entry.data[CONF_HOST] == "192.168.1.100" + + mock_probe_player.side_effect = None + mock_probe_player.return_value = WiimProbeResult( + host="192.168.1.111", + udn="uuid:test-udn-1234", + name="WiiM Pro", + location="http://192.168.1.111:49152/description.xml", + model="WiiM Pro", + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.111"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.111" + + +async def test_reconfigure_flow_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_probe_player: AsyncMock, +) -> None: + """Test reconfigure rejects a host that belongs to a different WiiM device.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + mock_probe_player.return_value = WiimProbeResult( + host="192.168.1.111", + udn="uuid:different-device", + name="Other WiiM", + location="http://192.168.1.111:49152/description.xml", + model="WiiM Pro", + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.111"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.100" From 28d2856475a44d3d9e831423db1ce2871e943cb8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 28 Jul 2026 12:37:43 +0200 Subject: [PATCH 02/15] Improve Reolink dual lens camera support (#177275) --- homeassistant/components/reolink/button.py | 2 +- homeassistant/components/reolink/icons.json | 6 + homeassistant/components/reolink/number.py | 4 +- homeassistant/components/reolink/select.py | 2 +- homeassistant/components/reolink/sensor.py | 2 +- homeassistant/components/reolink/strings.json | 3 + homeassistant/components/reolink/switch.py | 8 +- .../reolink/snapshots/test_switch.ambr | 1100 +++++++++++++++++ tests/components/reolink/test_init.py | 4 +- tests/components/reolink/test_switch.py | 30 + 10 files changed, 1153 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 7888be0fffca9f..2fb5e7d0c81309 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -204,7 +204,7 @@ async def async_setup_entry( entities: list[ReolinkButtonEntity | ReolinkHostButtonEntity] = [ ReolinkButtonEntity(reolink_data, channel, entity_description) for entity_description in BUTTON_ENTITIES - for channel in reolink_data.host.api.channels + for channel in reolink_data.host.api.stream_channels if entity_description.supported(reolink_data.host.api, channel) ] entities.extend( diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 7f5822f8d2a633..6e1943fafe5a1d 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -596,6 +596,12 @@ "on": "mdi:eye-off" } }, + "privacy_mask_lens": { + "default": "mdi:eye", + "state": { + "on": "mdi:eye-off" + } + }, "privacy_mode": { "default": "mdi:eye", "state": { diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 84f1fa1ce31172..5be6ef25bec5e3 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -966,13 +966,13 @@ async def async_setup_entry( entities: list[NumberEntity] = [ ReolinkNumberEntity(reolink_data, channel, entity_description) for entity_description in NUMBER_ENTITIES - for channel in api.channels + for channel in api.stream_channels if entity_description.supported(api, channel) ] entities.extend( ReolinkSmartAINumberEntity(reolink_data, channel, location, entity_description) for entity_description in SMART_AI_NUMBER_ENTITIES - for channel in api.channels + for channel in api.stream_channels for location in api.baichuan.smart_location_list( channel, entity_description.smart_type ) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 21ad32999d302d..41bb8129d287cc 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -434,7 +434,7 @@ async def async_setup_entry( entities: list[SelectEntity] = [ ReolinkSelectEntity(reolink_data, channel, entity_description) for entity_description in SELECT_ENTITIES - for channel in reolink_data.host.api.channels + for channel in reolink_data.host.api.stream_channels if entity_description.supported(reolink_data.host.api, channel) ] entities.extend( diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index a270898a89432d..e277d7b7979946 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -226,7 +226,7 @@ async def async_setup_entry( ] = [ ReolinkSensorEntity(reolink_data, channel, entity_description) for entity_description in SENSORS - for channel in reolink_data.host.api.channels + for channel in reolink_data.host.api.stream_channels if entity_description.supported(reolink_data.host.api, channel) ] entities.extend( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5df5df9c4136f6..f695251a1528c4 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -856,6 +856,9 @@ "privacy_mask": { "name": "Privacy mask" }, + "privacy_mask_lens": { + "name": "Privacy mask lens {channel}" + }, "privacy_mode": { "name": "Privacy mode" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 2d9dbf3a73b642..b09d44d9502d2c 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -258,6 +258,7 @@ class ReolinkSwitchIndexEntityDescription( key="privacy_mask", cmd_key="GetMask", translation_key="privacy_mask", + lens_entity=True, entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "privacy_mask"), value=lambda api, ch: api.privacy_mask_enabled(ch), @@ -358,7 +359,7 @@ async def async_setup_entry( entities: list[SwitchEntity] = [ ReolinkSwitchEntity(reolink_data, channel, entity_description) for entity_description in SWITCH_ENTITIES - for channel in reolink_data.host.api.channels + for channel in reolink_data.host.api.stream_channels if entity_description.supported(reolink_data.host.api, channel) ] entities.extend( @@ -401,6 +402,11 @@ def __init__( """Initialize Reolink switch entity.""" self.entity_description = entity_description super().__init__(reolink_data, channel) + if self.entity_description.lens_entity and self._host.api.is_dual_lens: + self._attr_translation_key = f"{entity_description.translation_key}_lens" + self._attr_translation_placeholders = { + "channel": str(self._channel), + } @property @override diff --git a/tests/components/reolink/snapshots/test_switch.ambr b/tests/components/reolink/snapshots/test_switch.ambr index d74c73e86775b7..0d1ac08c5606c1 100644 --- a/tests/components/reolink/snapshots/test_switch.ambr +++ b/tests/components/reolink/snapshots/test_switch.ambr @@ -1299,3 +1299,1103 @@ 'state': 'on', }) # --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_auto_focus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_auto_focus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto focus', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto focus', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_focus', + 'unique_id': 'ABC1234567D89EFG_0_auto_focus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_auto_focus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Auto focus', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_auto_focus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_auto_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_auto_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto tracking', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto tracking', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_tracking', + 'unique_id': 'ABC1234567D89EFG_0_auto_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_auto_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Auto tracking', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_auto_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_doorbell_button_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_doorbell_button_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell button sound', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell button sound', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_button_sound', + 'unique_id': 'ABC1234567D89EFG_0_doorbell_button_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_doorbell_button_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Doorbell button sound', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_doorbell_button_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_email_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_email_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Email on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Email on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'email', + 'unique_id': 'ABC1234567D89EFG_email', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_email_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Email on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_email_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_ftp_upload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_ftp_upload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'FTP upload', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FTP upload', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ftp_upload', + 'unique_id': 'ABC1234567D89EFG_ftp_upload', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_ftp_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name FTP upload', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_ftp_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_guard_return-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_guard_return', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard return', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guard return', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_return', + 'unique_id': 'ABC1234567D89EFG_0_gaurd_return', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_guard_return-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Guard return', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_guard_return', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_hardwired_chime_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_hardwired_chime_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hardwired chime enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hardwired chime enabled', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hardwired_chime_enabled', + 'unique_id': 'ABC1234567D89EFG_0_hardwired_chime_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_hardwired_chime_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Hardwired chime enabled', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_hardwired_chime_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_hub_ringtone_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_hub_ringtone_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub ringtone on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub ringtone on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_ringtone_on_event', + 'unique_id': 'ABC1234567D89EFG_buzzer', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_hub_ringtone_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Hub ringtone on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_hub_ringtone_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_infrared_lights_in_night_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_infrared_lights_in_night_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Infrared lights in night mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Infrared lights in night mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ir_lights', + 'unique_id': 'ABC1234567D89EFG_0_ir_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_infrared_lights_in_night_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Infrared lights in night mode', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_infrared_lights_in_night_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_manual_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_manual_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Manual record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Manual record', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_record', + 'unique_id': 'ABC1234567D89EFG_0_manual_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_manual_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Manual record', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_manual_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_pir_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_pir_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PIR enabled', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_enabled', + 'unique_id': 'ABC1234567D89EFG_0_pir_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_pir_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name PIR enabled', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_pir_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_pir_reduce_false_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_pir_reduce_false_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR reduce false alarm', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PIR reduce false alarm', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_reduce_alarm', + 'unique_id': 'ABC1234567D89EFG_0_pir_reduce_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_pir_reduce_false_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name PIR reduce false alarm', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_pir_reduce_false_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_pre_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_pre_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record', + 'unique_id': 'ABC1234567D89EFG_0_pre_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_pre_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Pre-recording', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_pre_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_pre_siren_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_pre_siren_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-siren on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-siren on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_siren_on_event', + 'unique_id': 'ABC1234567D89EFG_0_pre_siren_on_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_pre_siren_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Pre-siren on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_pre_siren_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_privacy_mask_lens_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_privacy_mask_lens_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Privacy mask lens 0', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mask lens 0', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mask_lens', + 'unique_id': 'ABC1234567D89EFG_0_privacy_mask', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_privacy_mask_lens_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Privacy mask lens 0', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_privacy_mask_lens_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_privacy_mask_lens_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_privacy_mask_lens_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Privacy mask lens 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mask lens 1', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mask_lens', + 'unique_id': 'ABC1234567D89EFG_1_privacy_mask', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_privacy_mask_lens_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Privacy mask lens 1', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_privacy_mask_lens_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_privacy_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_privacy_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Privacy mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode', + 'unique_id': 'ABC1234567D89EFG_0_privacy_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_privacy_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Privacy mode', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_privacy_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_ptz_patrol-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_reolink_name_ptz_patrol', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ patrol', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ patrol', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_patrol', + 'unique_id': 'ABC1234567D89EFG_0_ptz_patrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_ptz_patrol-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name PTZ patrol', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_ptz_patrol', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_push_notifications-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_push_notifications', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Push notifications', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Push notifications', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'push_notifications', + 'unique_id': 'ABC1234567D89EFG_push_notifications', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_push_notifications-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Push notifications', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_push_notifications', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record', + 'unique_id': 'ABC1234567D89EFG_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Record', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_record_audio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_record_audio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record audio', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record audio', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_audio', + 'unique_id': 'ABC1234567D89EFG_0_record_audio', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_record_audio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Record audio', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_record_audio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_siren_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_siren_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Siren on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_on_event', + 'unique_id': 'ABC1234567D89EFG_0_siren_on_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_dual_lens[switch.test_reolink_name_siren_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Siren on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_siren_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index fa15af4502eabc..832656d1cc3493 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -526,7 +526,7 @@ def mock_supported(ch, capability): config_entry=config_entry, suggested_object_id=original_id, disabled_by=None, - original_name="NEEDS_MIGRATION", + original_name="Zoom", ) entity_registry.async_get_or_create( @@ -549,7 +549,7 @@ def mock_supported(ch, capability): assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None entity_id = entity_registry.async_get_entity_id(domain, DOMAIN, new_id) - assert entity_registry.async_get(entity_id).original_name == "NEEDS_MIGRATION" + assert entity_registry.async_get(entity_id).original_name == "Zoom" async def test_migrate_with_already_existing_device( diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index c55bdd688491db..77cd8f0ff801e7 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -46,6 +46,36 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities_dual_lens( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + reolink_host: MagicMock, +) -> None: + """Test all entities.""" + + def mock_supported(ch, capability): + if capability == "privacy_mask": + return True + if ch is not None and ch > 0: + return False + return True + + reolink_host.is_nvr = False + reolink_host.is_dual_lens = True + reolink_host.stream_channels = [0, 1] + reolink_host.supported = mock_supported + + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + async def test_switch( hass: HomeAssistant, config_entry: MockConfigEntry, From 4a329fec75dbd3d1a812d33e0594778fa49b919d Mon Sep 17 00:00:00 2001 From: kohai-ut Date: Tue, 28 Jul 2026 04:44:36 -0600 Subject: [PATCH 03/15] Stop wirelesstag cloud push monitoring on shutdown (#172598) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/wirelesstag/__init__.py | 13 ++++++-- tests/components/wirelesstag/test_init.py | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/components/wirelesstag/test_init.py diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index ddd443f6bd96a6..9baeee476a29d2 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -10,8 +10,8 @@ from wirelesstagpy.exceptions import WirelessTagsException from homeassistant.components import persistent_notification -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType @@ -121,7 +121,16 @@ def push_callback( str(ex), ) + def _stop_monitoring(_event: Event) -> None: + """Stop cloud push monitoring on Home Assistant shutdown.""" + self.stop_monitoring() + self.api.start_monitoring(push_callback) + self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_monitoring) + + def stop_monitoring(self) -> None: + """Stop monitoring push events.""" + self.api.stop_monitoring() def setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/tests/components/wirelesstag/test_init.py b/tests/components/wirelesstag/test_init.py new file mode 100644 index 00000000000000..8a6c07943311e4 --- /dev/null +++ b/tests/components/wirelesstag/test_init.py @@ -0,0 +1,31 @@ +"""Tests for the Wireless Sensor Tags integration setup.""" + +from unittest.mock import patch + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +CONFIG = {"wirelesstag": {"username": "foo@bar.com", "password": "secret"}} + + +async def test_stop_monitoring_on_homeassistant_stop(hass: HomeAssistant) -> None: + """Test cloud push monitoring is stopped when Home Assistant stops. + + The monitoring worker runs in a non-daemon thread, so it must be stopped on + shutdown; otherwise Home Assistant cannot exit cleanly. + """ + with patch("homeassistant.components.wirelesstag.WirelessTags") as mock_api_class: + mock_api = mock_api_class.return_value + mock_api.load_tags.return_value = {} + + assert await async_setup_component(hass, "wirelesstag", CONFIG) + await hass.async_block_till_done() + + mock_api.start_monitoring.assert_called_once() + mock_api.stop_monitoring.assert_not_called() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_api.stop_monitoring.assert_called_once() From bfcfb57e0950379f20a284603934a58d8738414e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 28 Jul 2026 12:58:15 +0200 Subject: [PATCH 04/15] Improve Reolink PTZ stop support detection (#177273) --- homeassistant/components/reolink/button.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 2fb5e7d0c81309..8ea267cc500bbb 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -54,9 +54,7 @@ class ReolinkHostButtonEntityDescription( key="ptz_stop", translation_key="ptz_stop", enabled_default=lambda api, ch: api.supported(ch, "pan_tilt"), - supported=lambda api, ch: ( - api.supported(ch, "pan_tilt") or api.supported(ch, "zoom_basic") - ), + supported=lambda api, ch: api.supported(ch, "ptz_stop"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value), ), ReolinkButtonEntityDescription( From e6edc3b3c272d1552c593a7d173216babb82f004 Mon Sep 17 00:00:00 2001 From: Martin <32802427+mstu01@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:58:59 +0200 Subject: [PATCH 05/15] Fix swallowed exceptions in action handlers for Slack (#177049) --- homeassistant/components/slack/notify.py | 10 +- homeassistant/components/slack/strings.json | 5 + tests/components/slack/test_notify.py | 206 +++++++++++--------- 3 files changed, 129 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index efea07cb39ed96..f44e0b3a0c86d7 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -20,6 +20,7 @@ ) from homeassistant.const import ATTR_ICON, CONF_PATH from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import aiohttp_client, config_validation as cv, template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -34,6 +35,7 @@ ATTR_USERNAME, CONF_DEFAULT_CHANNEL, DATA_CLIENT, + DOMAIN, SLACK_DATA, ) from .utils import upload_file_to_slack @@ -278,10 +280,12 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: try: DATA_SCHEMA(data) - # pylint: disable-next=home-assistant-action-swallowed-exception except vol.Invalid as err: - _LOGGER.error("Invalid message data: %s", err) - data = {} + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_message_data", + translation_placeholders={"error": str(err)}, + ) from err title = kwargs.get(ATTR_TITLE) targets = _async_sanitize_channel_names( diff --git a/homeassistant/components/slack/strings.json b/homeassistant/components/slack/strings.json index cefa5a41263055..32cdfdfcd772d5 100644 --- a/homeassistant/components/slack/strings.json +++ b/homeassistant/components/slack/strings.json @@ -32,5 +32,10 @@ "name": "Do not disturb until" } } + }, + "exceptions": { + "invalid_message_data": { + "message": "Invalid message data: {error}" + } } } diff --git a/tests/components/slack/test_notify.py b/tests/components/slack/test_notify.py index 29c7e27d107edc..f31619381aa848 100644 --- a/tests/components/slack/test_notify.py +++ b/tests/components/slack/test_notify.py @@ -1,110 +1,138 @@ """Test slack notifications.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock + +import pytest from homeassistant.components import notify from homeassistant.components.slack import DOMAIN -from homeassistant.components.slack.notify import ( - ATTR_THREAD_TS, - CONF_DEFAULT_CHANNEL, - SlackNotificationService, +from homeassistant.components.slack.notify import ATTR_THREAD_TS +from homeassistant.const import ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import CONF_DATA, TEAM_ID, mock_connection + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +SERVICE_NAME = "test_team" + + +async def _async_setup_notify_service( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entry_data: dict[str, str], +) -> AsyncMock: + """Set up the slack integration and mock the message client.""" + entry = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=TEAM_ID) + entry.add_to_hass(hass) + mock_connection(aioclient_mock) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.services.has_service(notify.DOMAIN, SERVICE_NAME) + mock_fn = AsyncMock() + entry.runtime_data.client.chat_postMessage = mock_fn + return mock_fn + + +@pytest.mark.parametrize( + ("entry_icon", "service_data", "expected_key", "expected_icon"), + [ + pytest.param( + ":robot_face:", + {notify.ATTR_MESSAGE: "test"}, + "icon_emoji", + ":robot_face:", + id="default_emoji", + ), + pytest.param( + "default_icon", + {notify.ATTR_MESSAGE: "test", notify.ATTR_DATA: {ATTR_ICON: ":new:"}}, + "icon_emoji", + ":new:", + id="emoji_overrides_default", + ), + pytest.param( + "https://example.com/hass.png", + {notify.ATTR_MESSAGE: "test"}, + "icon_url", + "https://example.com/hass.png", + id="default_icon_url", + ), + pytest.param( + "default_icon", + { + notify.ATTR_MESSAGE: "test", + notify.ATTR_DATA: {ATTR_ICON: "https://example.com/hass.png"}, + }, + "icon_url", + "https://example.com/hass.png", + id="icon_url_overrides_default", + ), + ], ) -from homeassistant.const import ATTR_ICON, CONF_API_KEY, CONF_NAME, CONF_PLATFORM - -from . import CONF_DATA - -SERVICE_NAME = f"notify_{DOMAIN}" - -DEFAULT_CONFIG = { - notify.DOMAIN: [ - { - CONF_PLATFORM: DOMAIN, - CONF_NAME: SERVICE_NAME, - CONF_API_KEY: "12345", - CONF_DEFAULT_CHANNEL: "channel", - } - ] -} - - -async def test_message_includes_default_emoji() -> None: - """Tests that default icon is used when no message icon is given.""" - mock_client = Mock() - mock_client.chat_postMessage = AsyncMock() - expected_icon = ":robot_face:" - service = SlackNotificationService( - None, mock_client, CONF_DATA | {ATTR_ICON: expected_icon} +async def test_message_icon( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entry_icon: str, + service_data: dict[str, str], + expected_key: str, + expected_icon: str, +) -> None: + """Test that the message icon comes from the entry data or the service data.""" + mock_fn = await _async_setup_notify_service( + hass, aioclient_mock, CONF_DATA | {ATTR_ICON: entry_icon} ) - await service.async_send_message("test") - - mock_fn = mock_client.chat_postMessage - mock_fn.assert_called_once() - _, kwargs = mock_fn.call_args - assert kwargs["icon_emoji"] == expected_icon - - -async def test_message_emoji_overrides_default() -> None: - """Tests that overriding the default icon emoji when sending a message works.""" - mock_client = Mock() - mock_client.chat_postMessage = AsyncMock() - service = SlackNotificationService( - None, mock_client, CONF_DATA | {ATTR_ICON: "default_icon"} + await hass.services.async_call( + notify.DOMAIN, SERVICE_NAME, service_data, blocking=True ) - expected_icon = ":new:" - await service.async_send_message("test", data={"icon": expected_icon}) - - mock_fn = mock_client.chat_postMessage mock_fn.assert_called_once() _, kwargs = mock_fn.call_args - assert kwargs["icon_emoji"] == expected_icon + assert kwargs[expected_key] == expected_icon -async def test_message_includes_default_icon_url() -> None: - """Tests that overriding the default icon url when sending a message works.""" - mock_client = Mock() - mock_client.chat_postMessage = AsyncMock() - expected_icon = "https://example.com/hass.png" - service = SlackNotificationService( - None, mock_client, CONF_DATA | {ATTR_ICON: expected_icon} - ) - - await service.async_send_message("test") - - mock_fn = mock_client.chat_postMessage - mock_fn.assert_called_once() - _, kwargs = mock_fn.call_args - assert kwargs["icon_url"] == expected_icon - - -async def test_message_icon_url_overrides_default() -> None: - """Tests that overriding the default icon url when sending a message works.""" - mock_client = Mock() - mock_client.chat_postMessage = AsyncMock() - service = SlackNotificationService( - None, mock_client, CONF_DATA | {ATTR_ICON: "default_icon"} - ) - - expected_icon = "https://example.com/hass.png" - await service.async_send_message("test", data={ATTR_ICON: expected_icon}) - - mock_fn = mock_client.chat_postMessage - mock_fn.assert_called_once() - _, kwargs = mock_fn.call_args - assert kwargs["icon_url"] == expected_icon - - -async def test_message_as_reply() -> None: +async def test_message_as_reply( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Tests that a message pointer will be passed to Slack if specified.""" - mock_client = Mock() - mock_client.chat_postMessage = AsyncMock() - service = SlackNotificationService(None, mock_client, CONF_DATA) + mock_fn = await _async_setup_notify_service(hass, aioclient_mock, CONF_DATA) expected_ts = "1624146685.064129" - await service.async_send_message("test", data={ATTR_THREAD_TS: expected_ts}) + await hass.services.async_call( + notify.DOMAIN, + SERVICE_NAME, + { + notify.ATTR_MESSAGE: "test", + notify.ATTR_DATA: {ATTR_THREAD_TS: expected_ts}, + }, + blocking=True, + ) - mock_fn = mock_client.chat_postMessage mock_fn.assert_called_once() _, kwargs = mock_fn.call_args assert kwargs["thread_ts"] == expected_ts + + +async def test_invalid_message_data( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Tests that invalid message data raises an error and sends no message.""" + mock_fn = await _async_setup_notify_service(hass, aioclient_mock, CONF_DATA) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + notify.DOMAIN, + SERVICE_NAME, + { + notify.ATTR_MESSAGE: "test", + notify.ATTR_DATA: {"not_a_valid_key": "value"}, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_message_data" + mock_fn.assert_not_called() From fc70614b63da5abacf855dbed2eed69adacde7d6 Mon Sep 17 00:00:00 2001 From: Bryan <185078974@qq.com> Date: Tue, 28 Jul 2026 18:59:53 +0800 Subject: [PATCH 06/15] Discover aidot wifi lights over dhcp (#177437) Co-authored-by: s1eedz Co-authored-by: Josef Zweck --- homeassistant/components/aidot/manifest.json | 5 ++ .../components/aidot/quality_scale.yaml | 2 +- homeassistant/components/aidot/strings.json | 3 +- homeassistant/generated/dhcp.py | 4 ++ tests/components/aidot/test_config_flow.py | 59 ++++++++++++++++++- 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aidot/manifest.json b/homeassistant/components/aidot/manifest.json index d3eb75f855d08f..84e765b8720c76 100644 --- a/homeassistant/components/aidot/manifest.json +++ b/homeassistant/components/aidot/manifest.json @@ -3,6 +3,11 @@ "name": "AiDot", "codeowners": ["@s1eedz", "@HongBryan"], "config_flow": true, + "dhcp": [ + { + "hostname": "aidot" + } + ], "documentation": "https://www.home-assistant.io/integrations/aidot", "integration_type": "hub", "iot_class": "local_polling", diff --git a/homeassistant/components/aidot/quality_scale.yaml b/homeassistant/components/aidot/quality_scale.yaml index 28995a3411c2fb..be8004c76eb036 100644 --- a/homeassistant/components/aidot/quality_scale.yaml +++ b/homeassistant/components/aidot/quality_scale.yaml @@ -49,7 +49,7 @@ rules: devices: done diagnostics: todo discovery-update-info: todo - discovery: todo + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/aidot/strings.json b/homeassistant/components/aidot/strings.json index 1d27987383a739..9427624784530f 100644 --- a/homeassistant/components/aidot/strings.json +++ b/homeassistant/components/aidot/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 2c7dba6202db9d..279f49ddda7e25 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -11,6 +11,10 @@ "hostname": "neo-*", "macaddress": "FC0FE7*", }, + { + "domain": "aidot", + "hostname": "aidot", + }, { "domain": "airobot", "hostname": "airobot-thermostat-*", diff --git a/tests/components/aidot/test_config_flow.py b/tests/components/aidot/test_config_flow.py index c1712bc8227fcd..84a794d9563b90 100644 --- a/tests/components/aidot/test_config_flow.py +++ b/tests/components/aidot/test_config_flow.py @@ -7,15 +7,22 @@ import pytest from homeassistant.components.aidot.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import TEST_COUNTRY, TEST_EMAIL, TEST_LOGIN_RESP, TEST_PASSWORD from tests.common import MockConfigEntry +DHCP_SERVICE_INFO = DhcpServiceInfo( + hostname="aidot", + ip="192.168.1.100", + macaddress="001122334455", +) + async def test_config_flow_cloud_login_success( hass: HomeAssistant, mock_setup_entry: AsyncMock @@ -44,6 +51,56 @@ async def test_config_flow_cloud_login_success( assert result["result"].unique_id == TEST_LOGIN_RESP["id"] +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test DHCP discovery shows the user form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + +async def test_dhcp_discovery_aborts_if_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test DHCP discovery aborts when the integration is configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_aborts_if_already_in_progress( + hass: HomeAssistant, +) -> None: + """Test a duplicate DHCP discovery flow aborts.""" + first_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_SERVICE_INFO, + ) + assert first_result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + @pytest.mark.parametrize( ("exception", "error"), [ From 1fe5631276444eb64874f17db487c6814c772c1a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 28 Jul 2026 12:00:08 +0100 Subject: [PATCH 07/15] Bump aiomealie 2.0.0 (#177380) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- .../mealie/fixtures/get_recipe.json | 25 +- .../mealie/snapshots/test_diagnostics.ambr | 46 +++ .../mealie/snapshots/test_services.ambr | 301 ++++++++++++++++++ 5 files changed, 372 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index b86669364f706c..0d9613d6e15637 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.2.4"] + "requirements": ["aiomealie==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 645c58984f1894..c1ceadbb33729e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ aiolookin==1.0.0 aiolyric==2.1.1 # homeassistant.components.mealie -aiomealie==1.2.4 +aiomealie==2.0.0 # homeassistant.components.melcloud_home aiomelcloudhome==0.2.1 diff --git a/tests/components/mealie/fixtures/get_recipe.json b/tests/components/mealie/fixtures/get_recipe.json index 1ddb92e8507c93..61bf187b80ed8c 100644 --- a/tests/components/mealie/fixtures/get_recipe.json +++ b/tests/components/mealie/fixtures/get_recipe.json @@ -51,7 +51,26 @@ "slug": "original-sachertorte" } ], - "tools": [], + "tools": [ + { + "id": "1170e609-20d3-45b8-b0c7-3a4cfa614e88", + "name": "Backofen", + "slug": "backofen", + "onHand": false + }, + { + "id": "9ab522ad-c3be-4dad-8b4f-d9d53817f4d0", + "name": "Magimix blender", + "slug": "magimix-blender", + "onHand": false + }, + { + "id": "b4ca27dc-9bf6-48be-ad10-3e7056cb24bc", + "name": "Alluminio", + "slug": "alluminio", + "onHand": false + } + ], "rating": null, "orgURL": "https://www.sacher.com/en/original-sacher-torte/recipe/", "dateAdded": "2024-06-29", @@ -295,6 +314,8 @@ }, "assets": [], "notes": [], - "extras": {}, + "extras": { + "Energy Consumption": "5" + }, "comments": [] } diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index 003414fad15db6..546416b635e319 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -40,6 +40,8 @@ 'slug': 'roast-chicken', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '1 Hour 35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -82,6 +84,8 @@ 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '1 Hour', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -124,6 +128,8 @@ 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -164,6 +170,8 @@ 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -214,6 +222,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '1 Hour 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -264,6 +274,8 @@ 'tag_id': '941558d2-50d5-4c9d-8890-a0258f18d493', }), ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -309,6 +321,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -349,6 +363,8 @@ 'slug': 'miso-udon-noodles-with-spinach-and-tofu', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '25 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -410,6 +426,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -452,6 +470,23 @@ 'slug': 'receta-de-pollo-al-curry-en-10-minutos-con-video-incluido', 'tags': list([ ]), + 'tools': list([ + dict({ + 'name': 'Backofen', + 'slug': 'backofen', + 'tool_id': '1170e609-20d3-45b8-b0c7-3a4cfa614e88', + }), + dict({ + 'name': 'Magimix blender', + 'slug': 'magimix-blender', + 'tool_id': '9ab522ad-c3be-4dad-8b4f-d9d53817f4d0', + }), + dict({ + 'name': 'Alluminio', + 'slug': 'alluminio', + 'tool_id': 'b4ca27dc-9bf6-48be-ad10-3e7056cb24bc', + }), + ]), 'total_time': '10 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -552,6 +587,8 @@ 'tag_id': '7f99b04f-914a-408b-a057-511ca1125734', }), ]), + 'tools': list([ + ]), 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -597,6 +634,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -641,6 +680,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -683,6 +724,8 @@ 'slug': 'mousse-de-saumon', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '17 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -840,6 +883,7 @@ }), ]), 'shopping_list': dict({ + 'group_id': '9ed7c880-3e85-4955-9318-1443d6e080fe', 'list_id': '27edbaab-2ec6-441f-8490-0283ea77585f', 'name': 'Supermarket', }), @@ -992,6 +1036,7 @@ }), ]), 'shopping_list': dict({ + 'group_id': '9ed7c880-3e85-4955-9318-1443d6e080fe', 'list_id': 'e9d78ff2-4b23-4b77-a3a8-464827100b46', 'name': 'Freezer', }), @@ -1144,6 +1189,7 @@ }), ]), 'shopping_list': dict({ + 'group_id': '9ed7c880-3e85-4955-9318-1443d6e080fe', 'list_id': 'f8438635-8211-4be8-80d0-0aa42e37a5f2', 'name': 'Special groceries', }), diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 6298e711859eb1..0515011c176f3c 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -24,6 +24,8 @@ 'slug': 'tu6y', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -48,6 +50,8 @@ 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -72,6 +76,8 @@ 'slug': 'patates-douces-au-four-1', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -96,6 +102,8 @@ 'slug': 'sweet-potatoes', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -120,6 +128,8 @@ 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -204,6 +214,8 @@ 'tag_id': '7f99b04f-914a-408b-a057-511ca1125734', }), ]), + 'tools': list([ + ]), 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -288,6 +300,8 @@ 'tag_id': '7f99b04f-914a-408b-a057-511ca1125734', }), ]), + 'tools': list([ + ]), 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -312,6 +326,8 @@ 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -336,6 +352,8 @@ 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -360,6 +378,8 @@ 'slug': 'test123', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -384,6 +404,8 @@ 'slug': 'bureeto', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -408,6 +430,8 @@ 'slug': 'subway-double-cookies', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -432,6 +456,8 @@ 'slug': 'qwerty12345', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -466,6 +492,8 @@ 'tag_id': '941558d2-50d5-4c9d-8890-a0258f18d493', }), ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -490,6 +518,8 @@ 'slug': 'meatloaf', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -514,6 +544,8 @@ 'slug': 'richtig-rheinischer-sauerbraten', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '3 Hours 20 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -578,6 +610,8 @@ 'tag_id': 'ec303c13-a4f7-4de3-8a4f-d13b72ddd500', }), ]), + 'tools': list([ + ]), 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -602,6 +636,8 @@ 'slug': 'test-20240121', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -626,6 +662,8 @@ 'slug': 'loempia-bowl', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -650,6 +688,8 @@ 'slug': '5-ingredient-chocolate-mousse', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -704,6 +744,8 @@ 'tag_id': 'fee5e626-792c-479d-a265-81a0029047f2', }), ]), + 'tools': list([ + ]), 'total_time': '15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -738,6 +780,13 @@ 'tag_id': '0f80c5d5-d1ee-41ac-a949-54a76b446459', }), ]), + 'tools': list([ + dict({ + 'name': 'Backofen', + 'slug': 'backofen', + 'tool_id': '1170e609-20d3-45b8-b0c7-3a4cfa614e88', + }), + ]), 'total_time': '24h', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -762,6 +811,8 @@ 'slug': 'test-234234', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -786,6 +837,8 @@ 'slug': 'test-243', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -815,6 +868,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -850,6 +905,8 @@ 'slug': 'tarta-cytrynowa-z-beza', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '1 Hour', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -874,6 +931,8 @@ 'slug': 'martins-test-recipe', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -918,6 +977,8 @@ 'tag_id': 'c78edd8c-c96b-43fb-86c0-917ea5a08ac7', }), ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -942,6 +1003,8 @@ 'slug': 'my-test-recipe', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -966,6 +1029,8 @@ 'slug': 'my-test-receipe', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -990,6 +1055,8 @@ 'slug': 'patates-douces-au-four', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1014,6 +1081,8 @@ 'slug': 'easy-homemade-pizza-dough', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '2 Hours 30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1043,6 +1112,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1112,6 +1183,8 @@ 'tag_id': 'd77a2071-43ae-40b1-854d-ae995a766fba', }), ]), + 'tools': list([ + ]), 'total_time': '2 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1136,6 +1209,8 @@ 'slug': 'schnelle-kasespatzle', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1160,6 +1235,8 @@ 'slug': 'taco', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1184,6 +1261,8 @@ 'slug': 'vodkapasta', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1208,6 +1287,8 @@ 'slug': 'vodkapasta2', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1232,6 +1313,8 @@ 'slug': 'rub', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1291,6 +1374,8 @@ 'tag_id': '2a7c5386-5d26-44fa-8a08-81747ee7f132', }), ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1315,6 +1400,8 @@ 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1339,6 +1426,8 @@ 'slug': 'prova', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1363,6 +1452,8 @@ 'slug': 'pate-au-beurre-1', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1387,6 +1478,8 @@ 'slug': 'pate-au-beurre', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1411,6 +1504,8 @@ 'slug': 'sous-vide-cheesecake-recipe', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '2 Hours 10 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1435,6 +1530,8 @@ 'slug': 'the-bomb-mini-cheesecakes', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '1 Hour 30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1514,6 +1611,8 @@ 'tag_id': '024b30ca-53cb-4243-ba6b-d830610f2f48', }), ]), + 'tools': list([ + ]), 'total_time': '25 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1538,6 +1637,8 @@ 'slug': 'death-by-chocolate', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1582,6 +1683,8 @@ 'tag_id': '2b6283e2-b8e0-4b3d-90d9-66f322ca77aa', }), ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1621,6 +1724,8 @@ 'tag_id': '04d2aea8-fc9a-4f9b-9a87-8f15189ab6f9', }), ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1653,6 +1758,8 @@ 'slug': 'tu6y', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1677,6 +1784,8 @@ 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1701,6 +1810,8 @@ 'slug': 'patates-douces-au-four-1', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1725,6 +1836,8 @@ 'slug': 'sweet-potatoes', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1749,6 +1862,8 @@ 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1833,6 +1948,8 @@ 'tag_id': '7f99b04f-914a-408b-a057-511ca1125734', }), ]), + 'tools': list([ + ]), 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1917,6 +2034,8 @@ 'tag_id': '7f99b04f-914a-408b-a057-511ca1125734', }), ]), + 'tools': list([ + ]), 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1941,6 +2060,8 @@ 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1965,6 +2086,8 @@ 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -1989,6 +2112,8 @@ 'slug': 'test123', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2013,6 +2138,8 @@ 'slug': 'bureeto', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2037,6 +2164,8 @@ 'slug': 'subway-double-cookies', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2061,6 +2190,8 @@ 'slug': 'qwerty12345', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2095,6 +2226,8 @@ 'tag_id': '941558d2-50d5-4c9d-8890-a0258f18d493', }), ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2119,6 +2252,8 @@ 'slug': 'meatloaf', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2143,6 +2278,8 @@ 'slug': 'richtig-rheinischer-sauerbraten', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '3 Hours 20 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2207,6 +2344,8 @@ 'tag_id': 'ec303c13-a4f7-4de3-8a4f-d13b72ddd500', }), ]), + 'tools': list([ + ]), 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2231,6 +2370,8 @@ 'slug': 'test-20240121', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2255,6 +2396,8 @@ 'slug': 'loempia-bowl', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2279,6 +2422,8 @@ 'slug': '5-ingredient-chocolate-mousse', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2333,6 +2478,8 @@ 'tag_id': 'fee5e626-792c-479d-a265-81a0029047f2', }), ]), + 'tools': list([ + ]), 'total_time': '15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2367,6 +2514,13 @@ 'tag_id': '0f80c5d5-d1ee-41ac-a949-54a76b446459', }), ]), + 'tools': list([ + dict({ + 'name': 'Backofen', + 'slug': 'backofen', + 'tool_id': '1170e609-20d3-45b8-b0c7-3a4cfa614e88', + }), + ]), 'total_time': '24h', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2391,6 +2545,8 @@ 'slug': 'test-234234', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2415,6 +2571,8 @@ 'slug': 'test-243', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2444,6 +2602,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2479,6 +2639,8 @@ 'slug': 'tarta-cytrynowa-z-beza', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '1 Hour', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2503,6 +2665,8 @@ 'slug': 'martins-test-recipe', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2547,6 +2711,8 @@ 'tag_id': 'c78edd8c-c96b-43fb-86c0-917ea5a08ac7', }), ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2571,6 +2737,8 @@ 'slug': 'my-test-recipe', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2595,6 +2763,8 @@ 'slug': 'my-test-receipe', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2619,6 +2789,8 @@ 'slug': 'patates-douces-au-four', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2643,6 +2815,8 @@ 'slug': 'easy-homemade-pizza-dough', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '2 Hours 30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2672,6 +2846,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2741,6 +2917,8 @@ 'tag_id': 'd77a2071-43ae-40b1-854d-ae995a766fba', }), ]), + 'tools': list([ + ]), 'total_time': '2 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2765,6 +2943,8 @@ 'slug': 'schnelle-kasespatzle', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2789,6 +2969,8 @@ 'slug': 'taco', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2813,6 +2995,8 @@ 'slug': 'vodkapasta', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2837,6 +3021,8 @@ 'slug': 'vodkapasta2', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2861,6 +3047,8 @@ 'slug': 'rub', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2920,6 +3108,8 @@ 'tag_id': '2a7c5386-5d26-44fa-8a08-81747ee7f132', }), ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2944,6 +3134,8 @@ 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2968,6 +3160,8 @@ 'slug': 'prova', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -2992,6 +3186,8 @@ 'slug': 'pate-au-beurre-1', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3016,6 +3212,8 @@ 'slug': 'pate-au-beurre', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3040,6 +3238,8 @@ 'slug': 'sous-vide-cheesecake-recipe', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '2 Hours 10 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3064,6 +3264,8 @@ 'slug': 'the-bomb-mini-cheesecakes', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '1 Hour 30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3143,6 +3345,8 @@ 'tag_id': '024b30ca-53cb-4243-ba6b-d830610f2f48', }), ]), + 'tools': list([ + ]), 'total_time': '25 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3167,6 +3371,8 @@ 'slug': 'death-by-chocolate', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3211,6 +3417,8 @@ 'tag_id': '2b6283e2-b8e0-4b3d-90d9-66f322ca77aa', }), ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3250,6 +3458,8 @@ 'tag_id': '04d2aea8-fc9a-4f9b-9a87-8f15189ab6f9', }), ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3417,6 +3627,9 @@ ]), 'date_added': datetime.date(2024, 6, 29), 'description': 'The world’s most famous cake, the Original Sacher-Torte, is the consequence of several lucky twists of fate. The first was in 1832, when the Austrian State Chancellor, Prince Klemens Wenzel von Metternich, tasked his kitchen staff with concocting an extraordinary dessert to impress his special guests. As fortune had it, the chef had fallen ill that evening, leaving the apprentice chef, the then-16-year-old Franz Sacher, to perform this culinary magic trick. Metternich’s parting words to the talented teenager: “I hope you won’t disgrace me tonight.”', + 'extras': dict({ + 'Energy Consumption': '5', + }), 'group_id': '24477569-f6af-4b53-9e3f-6d04b0ca6916', 'household_id': None, 'image': 'SuPW', @@ -3688,6 +3901,23 @@ 'tag_id': 'd530b8e4-275a-4093-804b-6d0de154c206', }), ]), + 'tools': list([ + dict({ + 'name': 'Backofen', + 'slug': 'backofen', + 'tool_id': '1170e609-20d3-45b8-b0c7-3a4cfa614e88', + }), + dict({ + 'name': 'Magimix blender', + 'slug': 'magimix-blender', + 'tool_id': '9ab522ad-c3be-4dad-8b4f-d9d53817f4d0', + }), + dict({ + 'name': 'Alluminio', + 'slug': 'alluminio', + 'tool_id': 'b4ca27dc-9bf6-48be-ad10-3e7056cb24bc', + }), + ]), 'total_time': '2 hours 30 minutes', 'user_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0', }), @@ -3724,6 +3954,8 @@ 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3758,6 +3990,8 @@ 'slug': 'roast-chicken', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '1 Hour 35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3792,6 +4026,23 @@ 'slug': 'receta-de-pollo-al-curry-en-10-minutos-con-video-incluido', 'tags': list([ ]), + 'tools': list([ + dict({ + 'name': 'Backofen', + 'slug': 'backofen', + 'tool_id': '1170e609-20d3-45b8-b0c7-3a4cfa614e88', + }), + dict({ + 'name': 'Magimix blender', + 'slug': 'magimix-blender', + 'tool_id': '9ab522ad-c3be-4dad-8b4f-d9d53817f4d0', + }), + dict({ + 'name': 'Alluminio', + 'slug': 'alluminio', + 'tool_id': 'b4ca27dc-9bf6-48be-ad10-3e7056cb24bc', + }), + ]), 'total_time': '10 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3886,6 +4137,8 @@ 'tag_id': '7f99b04f-914a-408b-a057-511ca1125734', }), ]), + 'tools': list([ + ]), 'total_time': '5 Hours', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3920,6 +4173,8 @@ 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': None, 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3954,6 +4209,8 @@ 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '1 Hour', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -3993,6 +4250,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4037,6 +4296,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '1 Hour 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4081,6 +4342,8 @@ 'tag_id': '941558d2-50d5-4c9d-8890-a0258f18d493', }), ]), + 'tools': list([ + ]), 'total_time': '30 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4120,6 +4383,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4159,6 +4424,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '3 Hours 15 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4198,6 +4465,8 @@ 'tag_id': '78318c97-75c7-4d06-95b6-51ef8f4a0257', }), ]), + 'tools': list([ + ]), 'total_time': '35 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4232,6 +4501,8 @@ 'slug': 'miso-udon-noodles-with-spinach-and-tofu', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '25 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4266,6 +4537,8 @@ 'slug': 'mousse-de-saumon', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '17 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4293,6 +4566,9 @@ ]), 'date_added': datetime.date(2024, 6, 29), 'description': 'The world’s most famous cake, the Original Sacher-Torte, is the consequence of several lucky twists of fate. The first was in 1832, when the Austrian State Chancellor, Prince Klemens Wenzel von Metternich, tasked his kitchen staff with concocting an extraordinary dessert to impress his special guests. As fortune had it, the chef had fallen ill that evening, leaving the apprentice chef, the then-16-year-old Franz Sacher, to perform this culinary magic trick. Metternich’s parting words to the talented teenager: “I hope you won’t disgrace me tonight.”', + 'extras': dict({ + 'Energy Consumption': '5', + }), 'group_id': '24477569-f6af-4b53-9e3f-6d04b0ca6916', 'household_id': None, 'image': 'SuPW', @@ -4564,6 +4840,23 @@ 'tag_id': 'd530b8e4-275a-4093-804b-6d0de154c206', }), ]), + 'tools': list([ + dict({ + 'name': 'Backofen', + 'slug': 'backofen', + 'tool_id': '1170e609-20d3-45b8-b0c7-3a4cfa614e88', + }), + dict({ + 'name': 'Magimix blender', + 'slug': 'magimix-blender', + 'tool_id': '9ab522ad-c3be-4dad-8b4f-d9d53817f4d0', + }), + dict({ + 'name': 'Alluminio', + 'slug': 'alluminio', + 'tool_id': 'b4ca27dc-9bf6-48be-ad10-3e7056cb24bc', + }), + ]), 'total_time': '2 hours 30 minutes', 'user_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0', }), @@ -4599,6 +4892,8 @@ 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4637,6 +4932,8 @@ 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4675,6 +4972,8 @@ 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), @@ -4713,6 +5012,8 @@ 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), + 'tools': list([ + ]), 'total_time': '40 Minutes', 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', }), From 82ca35fd45eef4aabbd2b89cefe2afeefe547a6f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 13:18:16 +0200 Subject: [PATCH 08/15] Add WS command config/device_registry/list_composite_splits (#176693) --- .../components/config/device_registry.py | 40 ++++++ homeassistant/helpers/device_registry.py | 8 ++ .../components/config/test_device_registry.py | 116 ++++++++++++++++++ tests/helpers/test_device_registry.py | 44 +++++++ 4 files changed, 208 insertions(+) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index befbbb74850e55..468d2b1b01872b 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -17,6 +17,7 @@ def async_setup(hass: HomeAssistant) -> bool: """Enable the Device Registry views.""" + websocket_api.async_register_command(hass, websocket_list_composite_splits) websocket_api.async_register_command(hass, websocket_list_devices) websocket_api.async_register_command(hass, websocket_update_device) websocket_api.async_register_command( @@ -25,6 +26,45 @@ def async_setup(hass: HomeAssistant) -> bool: return True +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "config/device_registry/list_composite_splits", + } +) +def websocket_list_composite_splits( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle list composite device splits command. + + Maps every pre-migration composite device id that was removed by splitting the + device into one device per config entry to the ids of the devices which replaced + it, and which of those (if any) belongs to the composite's former primary config + entry. + """ + registry = dr.async_get(hass) + connection.send_result( + msg["id"], + { + composite_id: { + "split_ids": [device.id for device in devices], + "primary_id": next( + ( + device.id + for device in devices + if device.config_entry_id + == device.composite_primary_config_entry + ), + None, + ), + } + for composite_id, devices in registry.devices.get_composite_splits().items() + }, + ) + + @callback @websocket_api.websocket_command( { diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 7e1b4b95a3878e..2fa9e254849dd0 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1221,6 +1221,14 @@ def get_devices_for_composite_device_id( for key in self._composite_device_id_index.get(composite_device_id, ()) ] + def get_composite_splits(self) -> dict[str, list[DeviceEntry]]: + """Get the pre-migration composite device ids and the devices split from them.""" + data = self.data + return { + composite_device_id: [data[key] for key in keys] + for composite_device_id, keys in self._composite_device_id_index.items() + } + class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]): """Container for deleted device registry entries. diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 153d4f5c685fc1..c8e255309b3a70 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -1,6 +1,7 @@ """Test device_registry API.""" from datetime import datetime +from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest @@ -151,6 +152,121 @@ class Unserializable: device_registry.async_remove_device(device2.id) +def _storage_device_v1_12( + device_id: str, + config_entries: list[str], + primary_config_entry: str | None, + identifier: str, +) -> dict[str, Any]: + """Return a stored device in version 1.12 format.""" + return { + "area_id": None, + "config_entries": config_entries, + "config_entries_subentries": { + config_entry: [None] for config_entry in config_entries + }, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": device_id, + "identifiers": [["test", identifier]], + "labels": [], + "manufacturer": None, + "model": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "name_by_user": None, + "primary_config_entry": primary_config_entry, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_list_composite_splits( + hass: HomeAssistant, + client: MockHAClientWebSocket, + hass_storage: dict[str, Any], +) -> None: + """Test listing the devices pre-migration composite devices were split into.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite spanning two config entries, with entry_1 as primary + _storage_device_v1_12( + "compositea000000000000000000000", + [entry_1.entry_id, entry_2.entry_id], + entry_1.entry_id, + "a", + ), + # Composite spanning two config entries, without a primary + _storage_device_v1_12( + "compositeb000000000000000000000", + [entry_1.entry_id, entry_3.entry_id], + None, + "b", + ), + # Single config entry device, not split + _storage_device_v1_12( + "single0000000000000000000000000", + [entry_1.entry_id], + entry_1.entry_id, + "c", + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + splits_a = registry.async_get_devices_for_composite_device_id( + "compositea000000000000000000000" + ) + splits_b = registry.async_get_devices_for_composite_device_id( + "compositeb000000000000000000000" + ) + assert len(splits_a) == 2 + assert len(splits_b) == 2 + primary_a = next( + device for device in splits_a if device.config_entry_id == entry_1.entry_id + ) + + await client.send_json_auto_id( + {"type": "config/device_registry/list_composite_splits"} + ) + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == { + "compositea000000000000000000000": { + "split_ids": unordered([device.id for device in splits_a]), + "primary_id": primary_a.id, + }, + "compositeb000000000000000000000": { + "split_ids": unordered([device.id for device in splits_b]), + "primary_id": None, + }, + } + + @pytest.mark.parametrize( ("payload_key", "payload_value"), [ diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 00406ed14c8019..ed48c6952a1f77 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -8371,6 +8371,50 @@ async def test_restored_composite_preserves_primary_config_entry( assert composite.primary_config_entry in composite.config_entries +@pytest.mark.parametrize("load_registries", [False]) +async def test_get_composite_splits( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test getting the mapping of composite device ids to their split devices.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_b", "1")} + ) + + splits = device_registry.devices.get_composite_splits() + assert set(splits) == {COMPOSITE_ID} + assert {device.id for device in splits[COMPOSITE_ID]} == {split_a.id, split_b.id} + + # A device which is not split from a composite is not included + device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "2")} + ) + splits = device_registry.devices.get_composite_splits() + assert set(splits) == {COMPOSITE_ID} + assert {device.id for device in splits[COMPOSITE_ID]} == {split_a.id, split_b.id} + + # A removed split is dropped from the mapping + device_registry.async_remove_device(split_a.id) + splits = device_registry.devices.get_composite_splits() + assert {device.id for device in splits[COMPOSITE_ID]} == {split_b.id} + + # Removing the last split drops the composite id from the mapping + device_registry.async_remove_device(split_b.id) + assert device_registry.devices.get_composite_splits() == {} + + @pytest.mark.parametrize("load_registries", [False]) async def test_clear_config_entry_clears_composite_primary_config_entry( hass: HomeAssistant, hass_storage: dict[str, Any] From cba75b847493dcbd7234d1d58c45b1a3f79ab2dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 13:22:32 +0200 Subject: [PATCH 09/15] Clear Entity.device_entry when entity is detached from device (#177445) --- homeassistant/helpers/entity.py | 2 + tests/helpers/test_entity.py | 100 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 36d41094ee2f2e..553f99c9699367 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1620,6 +1620,8 @@ async def _async_process_registry_update_or_remove( if device_id := registry_entry.device_id: self.device_entry = dr.async_get(self.hass).async_get(device_id) + else: + self.device_entry = None if registry_entry.disabled: await self.async_remove() diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index b9b93db67bcc16..97fbb2e9608823 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1649,6 +1649,106 @@ async def async_setup_entry( assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name3 +async def test_device_entry_cleared_when_detached_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test device_entry of an entity attached to another entry's device. + + When the device is removed, the entity registry detaches the cross-entry + entity (a device_id=None update) instead of removing it; the detach must + clear the cached device_entry. + """ + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("other", "dev1")} + ) + + ent = MockEntity(unique_id="qwer") + + async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setup entry method.""" + async_add_entities([ent]) + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + # Attach the entity to the other config entry's device, as e.g. a + # ScannerEntity attaches to the tracked device by MAC + entity_registry.async_update_entity(ent.entity_id, device_id=device.id) + await hass.async_block_till_done() + assert ent.device_entry is not None + + device_registry.async_remove_device(device.id) + await hass.async_block_till_done() + + # The registry entry was detached from the removed device, clearing the + # cached device entry + registry_entry = entity_registry.async_get(ent.entity_id) + assert registry_entry is not None + assert registry_entry.device_id is None + assert ent.device_entry is None + + +async def test_device_entry_cleared_on_registry_detach( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the cached device entry is cleared when the entity is detached. + + A registry update setting device_id to None must clear the cached + device_entry, even while the device itself still exists. + """ + ent = MockEntity(unique_id="qwer") + + async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setup entry method.""" + async_add_entities([ent]) + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "dev1")} + ) + entity_registry.async_update_entity(ent.entity_id, device_id=device.id) + await hass.async_block_till_done() + assert ent.device_entry is not None + assert ent.device_entry.id == device.id + + # Detach the entity while the device remains; the cache must still clear + entity_registry.async_update_entity(ent.entity_id, device_id=None) + await hass.async_block_till_done() + + assert device_registry.async_get(device.id) is not None + assert ent.device_entry is None + + async def test_translation_key(hass: HomeAssistant) -> None: """Test translation key property.""" mock_entity1 = entity.Entity() From a87dccab33d4b9e35fdfadac20556b9df936a960 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 14:01:13 +0200 Subject: [PATCH 10/15] Disable pylint registry fixture rule in config WS test (#177454) --- tests/components/config/test_device_registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index c8e255309b3a70..dccf5a8246901e 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -235,6 +235,7 @@ async def test_list_composite_splits( dr.async_setup(hass) await dr.async_load(hass) + # pylint: disable-next=home-assistant-tests-registry-fixtures registry = dr.async_get(hass) splits_a = registry.async_get_devices_for_composite_device_id( From c211d6c03eee63bd70bc838f0d4d44444015a07a Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 28 Jul 2026 15:53:26 +0200 Subject: [PATCH 11/15] Fix legacy port redirect hanging Home Assistant shutdown (#177457) Co-authored-by: Claude Opus 5 (1M context) --- homeassistant/components/http/server.py | 23 ++++--- tests/components/http/test_init.py | 82 +++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/http/server.py b/homeassistant/components/http/server.py index 973bc1ec5cad40..e86cb8c7d31e91 100644 --- a/homeassistant/components/http/server.py +++ b/homeassistant/components/http/server.py @@ -612,13 +612,22 @@ async def _async_create_redirect_server(self) -> asyncio.Server: async def _async_stop_legacy_redirect(self) -> None: """Stop the legacy redirect server and free its port.""" - if self._legacy_redirect_server is not None: - self._legacy_redirect_server.close() - await self._legacy_redirect_server.wait_closed() - self._legacy_redirect_server = None - if self._legacy_redirect_runner is not None: - await self._legacy_redirect_runner.cleanup() - self._legacy_redirect_runner = None + server = self._legacy_redirect_server + runner = self._legacy_redirect_runner + # Drop the references up front: both the onboarding listener and the + # stop event tear the redirect down, so a second call must not wait on + # a teardown that is already in flight. + self._legacy_redirect_server = None + self._legacy_redirect_runner = None + if server is not None: + # Stop accepting new connections, but do not await wait_closed() + # yet: it only returns once every open connection is done, and the + # runner cleanup below is what actually closes them. + server.close() + if runner is not None: + await runner.cleanup() + if server is not None: + await server.wait_closed() async def stop(self) -> None: """Stop the aiohttp server.""" diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 6db736e40bbfb0..591ebaecf4b064 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1851,6 +1851,88 @@ async def _bind_serving(self: http.HomeAssistantHTTP) -> asyncio.Server: assert resp.headers["Location"] == "http://127.0.0.1/api/foo?bar=1" +async def test_legacy_redirect_stops_with_connection_open( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test the redirect tears down while a client holds a connection open. + + A client keeps its connection alive after the redirect response, so + awaiting the server's wait_closed() before the runner has closed the open + connections never returned, hanging every shutdown stage in turn. + """ + captured: dict[str, int] = {} + + async def _bind_serving(self: http.HomeAssistantHTTP) -> asyncio.Server: + assert self._legacy_redirect_runner is not None + server = await self.hass.loop.create_server( + self._legacy_redirect_runner.server, "127.0.0.1", 0 + ) + captured["port"] = server.sockets[0].getsockname()[1] + return server + + with ( + patch.dict(os.environ, {ENV_SUPERVISOR: "core"}, clear=True), + patch( + "homeassistant.components.http.config._DEFAULT_CONFIG", DEFAULT_80_CONFIG + ), + patch( + "homeassistant.components.http.server._DEFAULT_CONFIG", DEFAULT_80_CONFIG + ), + patch( + "homeassistant.components.http.server.HomeAssistantHTTP._async_create_redirect_server", + autospec=True, + side_effect=_bind_serving, + ), + ): + await _setup_http_with_onboarding(hass) + server = hass.http + assert server._legacy_redirect_server is not None + + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://127.0.0.1:{captured['port']}/", allow_redirects=False + ) as resp: + assert resp.status == 307 + + # The connection is returned to the pool, not closed, so the + # redirect still has an open connection at teardown. + _complete_onboarding(hass) + async with asyncio.timeout(10): + await hass.async_block_till_done() + + assert server._legacy_redirect_server is None + assert server._legacy_redirect_runner is None + + +async def test_legacy_redirect_stop_is_reentrant( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test overlapping redirect teardowns do not wait on each other. + + Onboarding completing and the stop event both tear the redirect down, so + the second call must not await the first call's server again. + """ + with _supervisor_default_config(): + await _setup_http_with_onboarding(hass) + server = hass.http + assert server._legacy_redirect_server is not None + + async with asyncio.timeout(10): + await asyncio.gather( + server._async_stop_legacy_redirect(), + server._async_stop_legacy_redirect(), + ) + + assert server._legacy_redirect_server is None + assert server._legacy_redirect_runner is None + + # The stop event fires the same teardown once more. + async with asyncio.timeout(10): + await server._async_stop_legacy_redirect() + + @pytest.mark.usefixtures("freezer") async def test_websocket_http_config( hass: HomeAssistant, From dbe9a6a41533b408a2809fa7a017478f796f8787 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 28 Jul 2026 16:03:09 +0200 Subject: [PATCH 12/15] Mark Z-Wave power management binary sensors as diagnostic (#177461) --- .../components/zwave_js/binary_sensor.py | 5 +++++ tests/components/zwave_js/test_binary_sensor.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 5b446e5079dc23..baab131f947910 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -351,6 +351,11 @@ class LegacyDoorStateRepairDescription: device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + NotificationZWaveJSEntityDescription( + # NotificationType 8: Power Management - All other State Id's + key=NOTIFICATION_POWER_MANAGEMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 9: System - State Id's 1, 2, 3, 4, 6, 7 key=NOTIFICATION_SYSTEM, diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 9acf8fc4fbffc7..36f907630bb190 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -329,6 +329,23 @@ async def test_notification_sensor( assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC +@pytest.mark.usefixtures("ring_keypad", "integration") +async def test_power_management_notification_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test Power Management notification sensors are diagnostic.""" + entity_id = "binary_sensor.keypad_v2_power_has_been_applied" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + + async def test_notification_off_state( hass: HomeAssistant, lock_popp_electric_strike_lock_control: Node, From a025ba90a7938e76fe9158956a5a6cf9ee1a7aa8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jul 2026 16:06:37 +0200 Subject: [PATCH 13/15] Add repair flow for template helper connected to composite device (#176924) --- homeassistant/components/template/__init__.py | 29 +- homeassistant/components/template/entity.py | 4 +- homeassistant/components/template/repairs.py | 88 ++++++ .../components/template/strings.json | 23 ++ tests/components/template/test_repairs.py | 281 ++++++++++++++++++ 5 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/template/repairs.py create mode 100644 tests/components/template/test_repairs.py diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 17f168fbabb4fe..2052ab8da8da68 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -16,7 +16,7 @@ ) from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryError, HomeAssistantError -from homeassistant.helpers import discovery +from homeassistant.helpers import device_registry as dr, discovery, issue_registry as ir from homeassistant.helpers.helper_integration import async_remove_helper_devices from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.service import async_register_admin_service @@ -91,15 +91,35 @@ async def _reload_config(call: Event | ServiceCall) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" + device_id = entry.options.get(CONF_DEVICE_ID) + # Clean up devices this helper created for previously selected source devices; # this can be removed in HA Core 2027.8. async_remove_helper_devices( hass, helper_config_entry_id=entry.entry_id, - source_device_id=entry.options.get(CONF_DEVICE_ID), + source_device_id=device_id, remove_all_devices=True, ) + if device_id is not None and dr.async_get(hass).async_is_composite_device_id( + device_id + ): + # The device was split into one device per config entry; ask the user to + # select a device again + ir.async_create_issue( + hass, + DOMAIN, + f"composite_device_id_{entry.entry_id}", + data={"entry_id": entry.entry_id}, + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="composite_device_id", + translation_placeholders={"name": entry.title}, + ) + else: + ir.async_delete_issue(hass, DOMAIN, f"composite_device_id_{entry.entry_id}") + for key in (CONF_MAX, CONF_MIN, CONF_STEP): if key not in entry.options: continue @@ -123,6 +143,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of a config entry.""" + ir.async_delete_issue(hass, DOMAIN, f"composite_device_id_{entry.entry_id}") + + async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index edcbc705682079..6bf941d3c0b08c 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -88,7 +88,9 @@ def __init__( ) device_registry = dr.async_get(hass) - if (device_id := config.get(CONF_DEVICE_ID)) is not None: + if ( + device_id := config.get(CONF_DEVICE_ID) + ) is not None and not device_registry.async_is_composite_device_id(device_id): self.device_entry = device_registry.async_get(device_id) @property diff --git a/homeassistant/components/template/repairs.py b/homeassistant/components/template/repairs.py new file mode 100644 index 00000000000000..276ea269f7d49e --- /dev/null +++ b/homeassistant/components/template/repairs.py @@ -0,0 +1,88 @@ +"""Repairs platform for the template integration.""" + +import voluptuous as vol + +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.selector import DeviceSelector + + +class CompositeDeviceIdRepairFlow(RepairsFlow): + """Handler to select a device again after the linked device was split.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the flow.""" + self._entry_id = entry.entry_id + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of the fix flow.""" + # The flow manager passes {"issue_id": ...} as user_input to this step; + # delegate so the form step can tell rendering from an (empty) submission + return await self.async_step_select_device() + + async def async_step_select_device( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the device selection step.""" + entry = self.hass.config_entries.async_get_entry(self._entry_id) + if entry is None: + return self.async_abort(reason="entry_removed") + + device_registry = dr.async_get(self.hass) + errors: dict[str, str] = {} + if user_input is not None: + device_id = user_input.get(CONF_DEVICE_ID) + if device_id is None or not device_registry.async_is_composite_device_id( + device_id + ): + options = {**entry.options} + if device_id: + options[CONF_DEVICE_ID] = device_id + else: + options.pop(CONF_DEVICE_ID, None) + self.hass.config_entries.async_update_entry(entry, options=options) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_create_entry(data={}) + # The suggested composite device id was submitted unchanged + errors[CONF_DEVICE_ID] = "invalid_device" + + return self.async_show_form( + step_id="select_device", + data_schema=vol.Schema( + { + vol.Optional( + CONF_DEVICE_ID, + description={ + "suggested_value": entry.options.get(CONF_DEVICE_ID) + }, + ): DeviceSelector(), + } + ), + description_placeholders={"name": entry.title}, + errors=errors, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create a fix flow.""" + if ( + issue_id.startswith("composite_device_id_") + and data is not None + and (entry := hass.config_entries.async_get_entry(str(data["entry_id"]))) + is not None + ): + return CompositeDeviceIdRepairFlow(entry) + return ConfirmRepairFlow() diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 8c9028e03b9fa5..4a6ecd4f1dfc88 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -582,6 +582,29 @@ } }, "issues": { + "composite_device_id": { + "fix_flow": { + "abort": { + "entry_removed": "The template helper has been removed" + }, + "error": { + "invalid_device": "The selected device no longer exists" + }, + "step": { + "select_device": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "device_id": "The device this template helper should be linked to." + }, + "description": "The template helper {name} was linked to a device which has been split into multiple devices. Until a device is selected, the helper is not linked to any device.\n\nSelect the device this helper should be linked to, or leave empty to not link it to a device.", + "title": "Select device for {name}" + } + } + }, + "title": "Device for template helper {name} needs to be selected again" + }, "config_format_actions": { "description": "There is an actions option in the template configuration that does not pair with a trigger option. This will be an configuration validation error in 2026.5.\n\n Please add a trigger to the configuration or remove the actions option from the configuration.\n\n```\n{config}\n```", "title": "Template actions option requires a trigger" diff --git a/tests/components/template/test_repairs.py b/tests/components/template/test_repairs.py new file mode 100644 index 00000000000000..5e5f004abcd63c --- /dev/null +++ b/tests/components/template/test_repairs.py @@ -0,0 +1,281 @@ +"""Tests for the template repairs platform.""" + +import attr +import pytest + +from homeassistant.components.template.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow +from tests.typing import ClientSessionGenerator + +COMPOSITE_ID = "composite00000000000000000000ab" +TEMPLATE_ENTITY_ID = "sensor.my_template" +EXPECTED_DATA_SCHEMA = [ + { + "description": {"suggested_value": COMPOSITE_ID}, + "name": "device_id", + "optional": True, + "required": False, + "selector": {"device": {"multiple": False}}, + } +] + + +@pytest.fixture +def split_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> tuple[dr.DeviceEntry, dr.DeviceEntry]: + """Create two devices which are splits of a pre-migration composite device.""" + entry_1 = MockConfigEntry(domain="itg1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="itg2") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + identifiers={("itg1", "1")}, + name="Split device 1", + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("itg2", "1")}, + name="Split device 2", + ) + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=COMPOSITE_ID + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=COMPOSITE_ID + ) + return device_registry.devices[device_1.id], device_registry.devices[device_2.id] + + +async def _setup_template_entry( + hass: HomeAssistant, device_id: str | None +) -> MockConfigEntry: + """Set up a sensor template config entry linked to a device.""" + options = { + "name": "My template", + "state": "{{10}}", + "template_type": "sensor", + } + if device_id is not None: + options[CONF_DEVICE_ID] = device_id + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=options, + title="My template", + ) + template_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + return template_config_entry + + +@pytest.mark.usefixtures("split_devices") +async def test_composite_device_id_creates_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a composite device id in the config entry options creates an issue.""" + entry = await _setup_template_entry(hass, COMPOSITE_ID) + + issue = issue_registry.async_get_issue( + DOMAIN, f"composite_device_id_{entry.entry_id}" + ) + assert issue is not None + assert issue.translation_placeholders == {"name": "My template"} + + # The template entity is not linked to the composite device + entity_entry = entity_registry.async_get(TEMPLATE_ENTITY_ID) + assert entity_entry is not None + assert entity_entry.device_id is None + + +async def test_live_device_id_no_issue( + hass: HomeAssistant, + split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry], + issue_registry: ir.IssueRegistry, +) -> None: + """Test a live device id in the config entry options creates no issue.""" + entry = await _setup_template_entry(hass, split_devices[0].id) + + assert not issue_registry.async_get_issue( + DOMAIN, f"composite_device_id_{entry.entry_id}" + ) + + +@pytest.mark.usefixtures("split_devices") +async def test_no_device_id_no_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a config entry without a device id creates no issue.""" + entry = await _setup_template_entry(hass, None) + + assert not issue_registry.async_get_issue( + DOMAIN, f"composite_device_id_{entry.entry_id}" + ) + + +@pytest.mark.parametrize( + ("pick_device", "expected_device_key_in_options"), + [ + pytest.param(True, True, id="pick_device"), + pytest.param(False, False, id="unlink"), + ], +) +async def test_composite_device_id_repair_flow( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry], + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + pick_device: bool, + expected_device_key_in_options: bool, +) -> None: + """Test fixing a composite device id via the repair flow.""" + device_1 = split_devices[0] + picked_device_id = device_1.id if pick_device else None + entry = await _setup_template_entry(hass, COMPOSITE_ID) + issue_id = f"composite_device_id_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_device" + assert result["description_placeholders"] == {"name": "My template"} + assert result["data_schema"] == EXPECTED_DATA_SCHEMA + + user_input = {CONF_DEVICE_ID: picked_device_id} if pick_device else {} + result = await process_repair_fix_flow(client, result["flow_id"], json=user_input) + assert result["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + assert (CONF_DEVICE_ID in entry.options) == expected_device_key_in_options + assert entry.options.get(CONF_DEVICE_ID) == picked_device_id + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + entity_entry = entity_registry.async_get(TEMPLATE_ENTITY_ID) + assert entity_entry is not None + assert entity_entry.device_id == picked_device_id + + +async def test_composite_device_id_repair_flow_ambiguity_not_resolved( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + split_devices: tuple[dr.DeviceEntry, dr.DeviceEntry], + issue_registry: ir.IssueRegistry, +) -> None: + """Test the form is shown again if the submitted device is a composite device.""" + device_1 = split_devices[0] + entry = await _setup_template_entry(hass, COMPOSITE_ID) + issue_id = f"composite_device_id_{entry.entry_id}" + + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + # Submitting the suggested composite device id does not resolve the ambiguity + result = await process_repair_fix_flow( + client, result["flow_id"], json={CONF_DEVICE_ID: COMPOSITE_ID} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_device" + assert result["data_schema"] == EXPECTED_DATA_SCHEMA + assert result["errors"] == {CONF_DEVICE_ID: "invalid_device"} + assert entry.options[CONF_DEVICE_ID] == COMPOSITE_ID + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + result = await process_repair_fix_flow( + client, result["flow_id"], json={CONF_DEVICE_ID: device_1.id} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + assert entry.options[CONF_DEVICE_ID] == device_1.id + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.usefixtures("split_devices") +async def test_composite_device_id_repair_flow_entry_removed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test the flow aborts if the config entry is removed while the flow is open.""" + entry = await _setup_template_entry(hass, COMPOSITE_ID) + issue_id = f"composite_device_id_{entry.entry_id}" + + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert result["type"] == FlowResultType.FORM + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + result = await process_repair_fix_flow(client, result["flow_id"], json={}) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "entry_removed" + + +@pytest.mark.usefixtures("split_devices") +async def test_issue_deleted_on_entry_removal( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the issue is deleted when the config entry is removed.""" + entry = await _setup_template_entry(hass, COMPOSITE_ID) + issue_id = f"composite_device_id_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.usefixtures("split_devices") +async def test_issue_deleted_when_device_fixed_in_options( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the issue is deleted on reload after the device id was corrected.""" + entry = await _setup_template_entry(hass, COMPOSITE_ID) + issue_id = f"composite_device_id_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + other_entry = MockConfigEntry(domain="itg3") + other_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("itg3", "1")} + ) + hass.config_entries.async_update_entry( + entry, options={**entry.options, CONF_DEVICE_ID: device.id} + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) From 7b9c969f9006728abf10eeed748bb58bfece5961 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:32:45 +0200 Subject: [PATCH 14/15] Resolve colliding device identifiers and connections (#177192) --- homeassistant/config_entries.py | 31 +- homeassistant/helpers/device_registry.py | 332 ++++++++++-- tests/helpers/test_device_registry.py | 656 ++++++++++++++++++++++- 3 files changed, 939 insertions(+), 80 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9f1e2839be954b..dd68e36551fe25 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1239,24 +1239,25 @@ async def _async_process_on_unload(self, hass: HomeAssistant) -> None: if job := self._on_unload.pop()(): self.async_create_task(hass, job, eager_start=True) - if not self._tasks and not self._background_tasks: - return + if self._tasks or self._background_tasks: + cancel_message = f"Config entry {self.title} with {self.domain} unloading" + for task in self._background_tasks: + task.cancel(cancel_message) - cancel_message = f"Config entry {self.title} with {self.domain} unloading" - for task in self._background_tasks: - task.cancel(cancel_message) + _, pending = await asyncio.wait( + [*self._tasks, *self._background_tasks], timeout=10 + ) - _, pending = await asyncio.wait( - [*self._tasks, *self._background_tasks], timeout=10 - ) + for task in pending: + self.logger.warning( + "Unloading %s (%s) config entry. Task %s did not complete in time", + self.title, + self.domain, + task, + ) - for task in pending: - self.logger.warning( - "Unloading %s (%s) config entry. Task %s did not complete in time", - self.title, - self.domain, - task, - ) + if (dev_reg := hass.data.get(dr.DATA_REGISTRY)) is not None: + dev_reg.async_config_entry_unloaded(self.entry_id) @callback def async_on_state_change(self, func: CALLBACK_TYPE) -> CALLBACK_TYPE: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 2fa9e254849dd0..f0ebb3cd1e759c 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -12,7 +12,7 @@ import os import shutil import time -from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack, override +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, Unpack, override import attr from yarl import URL @@ -1024,6 +1024,13 @@ async def _async_backup_store(self) -> None: _LOGGER.info("Backed up %s to %s before migration", source, backup) +class _CollidingKeys(NamedTuple): + """Identifiers and connections shared with a colliding device.""" + + identifiers: set[tuple[str, str]] + connections: set[tuple[str, str]] + + class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( BaseRegistryItems[_EntryTypeT] ): @@ -1034,6 +1041,12 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( connection or identifier to the devices that have it, keyed by config entry id: - (connection_type, connection identifier) -> {config_entry_id: entry} - (DOMAIN, identifier) -> {config_entry_id: entry} + + Registry bugs used to allow duplicate keys within a config entry, so old stores + can hold them. Only the last indexed device occupies the slot (matching historic + lookup behavior); the others are recorded as shadowed until reconciled: + - (config_entry_id, (connection_type, connection identifier)) -> {device_id} + - (config_entry_id, (DOMAIN, identifier)) -> {device_id} """ def __init__(self) -> None: @@ -1041,44 +1054,95 @@ def __init__(self) -> None: super().__init__() self._connections: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} self._identifiers: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} + self._shadowed_connections: dict[ + tuple[str | None, tuple[str, str]], set[str] + ] = {} + self._shadowed_identifiers: dict[ + tuple[str | None, tuple[str, str]], set[str] + ] = {} @override def _index_entry(self, key: str, entry: _EntryTypeT) -> None: """Index an entry.""" - config_entry_id = entry.config_entry_id for connection in entry.connections: - self._connections.setdefault(connection, {})[config_entry_id] = entry + self._index_key( + connection, + entry, + self._connections, + self._shadowed_connections, + ) for identifier in entry.identifiers: - self._identifiers.setdefault(identifier, {})[config_entry_id] = entry + self._index_key( + identifier, + entry, + self._identifiers, + self._shadowed_identifiers, + ) + + def _index_key( + self, + key: tuple[str, str], + new_device: _EntryTypeT, + index: dict[tuple[str, str], dict[str | None, _EntryTypeT]], + shadowed_index: dict[tuple[str | None, tuple[str, str]], set[str]], + ) -> None: + """Index one key, recording a displaced device as shadowed.""" + by_config_entry = index.setdefault(key, {}) + config_entry_id = new_device.config_entry_id + if ( + existing := by_config_entry.get(config_entry_id) + ) is not None and existing.id != new_device.id: + shadowed_index.setdefault((config_entry_id, key), set()).add(existing.id) + by_config_entry[config_entry_id] = new_device @override def _unindex_entry( self, key: str, replacement_entry: _EntryTypeT | None = None ) -> None: - """Unindex an entry. + """Unindex an entry.""" + old_device = self.data[key] + for connection in old_device.connections: + self._unindex_key( + connection, + old_device, + self._connections, + self._shadowed_connections, + ) + for identifier in old_device.identifiers: + self._unindex_key( + identifier, + old_device, + self._identifiers, + self._shadowed_identifiers, + ) - Guards against collisions, the code below can be simplified once - collisions are not longer allowed, refer to commit history in PR - 175785. - """ - old_entry = self.data[key] - config_entry_id = old_entry.config_entry_id - for connection in old_entry.connections: - by_config_entry = self._connections.get(connection) - if by_config_entry is not None and ( - by_config_entry.get(config_entry_id) is old_entry - ): - del by_config_entry[config_entry_id] - if not by_config_entry: - del self._connections[connection] - for identifier in old_entry.identifiers: - by_config_entry = self._identifiers.get(identifier) - if by_config_entry is not None and ( - by_config_entry.get(config_entry_id) is old_entry - ): + def _unindex_key( + self, + key: tuple[str, str], + old_device: _EntryTypeT, + index: dict[tuple[str, str], dict[str | None, _EntryTypeT]], + shadowed_index: dict[tuple[str | None, tuple[str, str]], set[str]], + ) -> None: + """Unindex one key, promoting a shadowed device into the slot.""" + by_config_entry = index[key] + config_entry_id = old_device.config_entry_id + shadow_key = (config_entry_id, key) + shadowed_ids = shadowed_index.get(shadow_key) + + if by_config_entry[config_entry_id] is old_device: + if shadowed_ids: + by_config_entry[config_entry_id] = self.data[shadowed_ids.pop()] + else: del by_config_entry[config_entry_id] if not by_config_entry: - del self._identifiers[identifier] + del index[key] + else: + # Not the slot holder, so it must be shadowed + assert shadowed_ids is not None + shadowed_ids.remove(old_device.id) + + if shadowed_ids is not None and not shadowed_ids: + del shadowed_index[shadow_key] def get_entry( self, @@ -1140,6 +1204,72 @@ def get_entries( entries[scoped.id] = scoped return list(entries.values()) + def get_colliding_device_ids( + self, + identifiers: set[tuple[str, str]], + connections: set[tuple[str, str]], + *, + config_entry_id: str, + exclude_device_id: str | None, + ) -> dict[str, _CollidingKeys]: + """Get the ids of other same-config-entry devices holding the given keys. + + Returns a map from the id of each colliding device to the identifiers and + connections it shares with the given ones. Includes devices shadowed in the + index. connections must be normalized. + """ + colliding: dict[str, _CollidingKeys] = {} + for identifier in identifiers: + for holder_id in self._holder_device_ids( + identifier, + config_entry_id, + self._identifiers, + self._shadowed_identifiers, + ): + if holder_id != exclude_device_id: + colliding.setdefault( + holder_id, _CollidingKeys(set(), set()) + ).identifiers.add(identifier) + for connection in connections: + for holder_id in self._holder_device_ids( + connection, + config_entry_id, + self._connections, + self._shadowed_connections, + ): + if holder_id != exclude_device_id: + colliding.setdefault( + holder_id, _CollidingKeys(set(), set()) + ).connections.add(connection) + return colliding + + def _holder_device_ids( + self, + key: tuple[str, str], + config_entry_id: str, + index: dict[tuple[str, str], dict[str | None, _EntryTypeT]], + shadowed_index: dict[tuple[str | None, tuple[str, str]], set[str]], + ) -> list[str]: + """Get a list of ids of the config entry's devices holding a key.""" + holder_device_ids: list[str] = [] + if (by_config_entry := index.get(key)) is not None and ( + slot_holder := by_config_entry.get(config_entry_id) + ) is not None: + holder_device_ids.append(slot_holder.id) + holder_device_ids.extend(shadowed_index.get((config_entry_id, key), ())) + return holder_device_ids + + def count_shadowed_keys(self) -> int: + """Count keys registered to multiple devices of one config entry.""" + return sum( + len(device_ids) + for shadowed_index in ( + self._shadowed_connections, + self._shadowed_identifiers, + ) + for device_ids in shadowed_index.values() + ) + class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): """Container for active (non-deleted) device registry entries.""" @@ -1316,6 +1446,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): def __init__(self, hass: HomeAssistant) -> None: """Initialize the device registry.""" self.hass = hass + # Devices registered through async_get_or_create in the current setup session + # of their config entry, keyed by config entry id. A key collision with one of + # these raises; one with a not yet registered device is reconciled. + self._live_device_ids: dict[str, set[str]] = {} self._loaded_event = asyncio.Event() self._store = DeviceRegistryStore( hass, @@ -1717,6 +1851,14 @@ def async_get_or_create( config_entry_id=config_entry_id, ) + self._async_reconcile_collisions( + device, config_entry, device_info, identifiers, connections + ) + if device is not None: + # Reconciliation can update the matched device (e.g. detach its via link) + device = self.devices[device.id] + + # Resolved after reconciliation so a removed stale duplicate can't be linked if via_device_id is not UNDEFINED and via_device_id is not None: resolved_via_device_id = self._resolve_via_device_id( via_device_id, config_entry_id @@ -1809,6 +1951,8 @@ def async_get_or_create( breaks_in_ha_version="2027.8.0", ) + self._async_purge_colliding_deleted_devices(device, identifiers, connections) + if default_manufacturer is not UNDEFINED and device.manufacturer is None: validated_fields["manufacturer"] = default_manufacturer @@ -1855,7 +1999,7 @@ def async_get_or_create( # can be removed in HA Core 2027.8. identifiers_connections: dict[str, Any] has_composite_identifiers: bool | UndefinedType = UNDEFINED - if not is_new and device.has_composite_identifiers: + if device.has_composite_identifiers: identifiers_connections = { "new_connections": connections, "new_identifiers": identifiers, @@ -1869,7 +2013,6 @@ def async_get_or_create( device = self._async_update_device( device.id, - allow_collisions=True, disabled_by=disabled_by, entry_type=entry_type, is_new=is_new, @@ -1885,6 +2028,7 @@ def async_get_or_create( # This is safe because _async_update_device will always return a device # in this use case. assert device + self._live_device_ids.setdefault(device.config_entry_id, set()).add(device.id) return device @callback @@ -1894,8 +2038,8 @@ def _async_update_device( # noqa: C901 *, add_config_entry_id: str | UndefinedType = UNDEFINED, add_config_subentry_id: str | None | UndefinedType = UNDEFINED, - # Temporary flag so we don't blow up when collisions are implicitly introduced - # by calls to async_get_or_create. + # Only set when stripping colliding keys from a stale device: its retained + # keys can still be duplicated in other stale devices and must not validate. allow_collisions: bool = False, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, @@ -2183,13 +2327,13 @@ def _async_update_device( # noqa: C901 if new_connections is not UNDEFINED: added_connections = new_values["connections"] = self._validate_connections( - device_id, effective_config_entry_id, new_connections, False + device_id, effective_config_entry_id, new_connections, allow_collisions ) old_values["connections"] = old.connections if new_identifiers is not UNDEFINED: added_identifiers = new_values["identifiers"] = self._validate_identifiers( - device_id, effective_config_entry_id, new_identifiers, False + device_id, effective_config_entry_id, new_identifiers, allow_collisions ) old_values["identifiers"] = old.identifiers @@ -2305,17 +2449,14 @@ def _async_update_device( # noqa: C901 else: match_identifiers = added_identifiers match_connections = added_connections - for deleted_device in self.deleted_devices.get_entries( - match_identifiers, match_connections + # A deleted device holding an identity the device now owns can never restore + for deleted_device_id in self.deleted_devices.get_colliding_device_ids( + match_identifiers or set(), + match_connections or set(), + config_entry_id=effective_config_entry_id, + exclude_device_id=None, ): - # get_entries matches across config entries, but identifiers/connections are - # unique per config entry - only remove the deleted device owned by this - # device's config entry, so another entry can still restore its own. - if ( - deleted_device.config_entry_id == effective_config_entry_id - and deleted_device.id in self.deleted_devices - ): - del self.deleted_devices[deleted_device.id] + del self.deleted_devices[deleted_device_id] # If its only run time attributes (suggested_area) # that do not get saved we do not want to write @@ -2472,6 +2613,98 @@ def async_update_device( **validated_fields, ) + @callback + def _async_reconcile_collisions( + self, + matched_device: DeviceEntry | None, + config_entry: ConfigEntry, + device_info: DeviceInfo, + identifiers: set[tuple[str, str]], + connections: set[tuple[str, str]], + ) -> None: + """Resolve device key collisions with the registering device. + + Shared keys are stripped from stale duplicates (devices not registered this + setup session); a duplicate left without any keys is removed. A collision + with a device registered this setup session raises. + """ + matched_device_id: str | None = None + if matched_device is not None: + matched_device_id = matched_device.id + if not matched_device.has_composite_identifiers: + identifiers = matched_device.identifiers | identifiers + connections = matched_device.connections | connections + colliding = self.devices.get_colliding_device_ids( + identifiers, + connections, + config_entry_id=config_entry.entry_id, + exclude_device_id=matched_device_id, + ) + live_device_ids = self._live_device_ids.get(config_entry.entry_id, ()) + for holder_id, (shared_identifiers, shared_connections) in colliding.items(): + if holder_id not in live_device_ids: + continue + raise DeviceInfoError( + config_entry.domain, + device_info, + f"identifiers or connections " + f"{sorted(shared_identifiers | shared_connections)} are already " + f"registered for device {holder_id} of the same config entry", + ) + for holder_id, (shared_identifiers, shared_connections) in colliding.items(): + holder = self.devices[holder_id] + remaining_identifiers = holder.identifiers - shared_identifiers + remaining_connections = holder.connections - shared_connections + if not remaining_identifiers and not remaining_connections: + _LOGGER.debug( + "Removing device %s, its identifiers and connections are all " + "registered by another device of the same config entry", + holder_id, + ) + self.async_remove_device(holder_id) + continue + _LOGGER.debug( + "Stripping %s from device %s, registered by another device of the " + "same config entry", + sorted(shared_identifiers | shared_connections), + holder_id, + ) + strip_values: dict[str, Any] = {} + if shared_identifiers: + strip_values["new_identifiers"] = remaining_identifiers + if shared_connections: + strip_values["new_connections"] = remaining_connections + self._async_update_device(holder_id, allow_collisions=True, **strip_values) + + @callback + def _async_purge_colliding_deleted_devices( + self, + device: DeviceEntry, + identifiers: set[tuple[str, str]], + connections: set[tuple[str, str]], + ) -> None: + """Purge deleted devices with key collisions.""" + if not device.has_composite_identifiers: + identifiers = device.identifiers | identifiers + connections = device.connections | connections + colliding = self.deleted_devices.get_colliding_device_ids( + identifiers, + connections, + config_entry_id=device.config_entry_id, + exclude_device_id=None, + ) + if not colliding: + return + for deleted_device_id in colliding: + _LOGGER.debug( + "Removing deleted device %s, its identifiers or connections are " + "registered by device %s of the same config entry", + deleted_device_id, + device.id, + ) + del self.deleted_devices[deleted_device_id] + self.async_schedule_save() + @callback def _validate_connections( self, @@ -2710,6 +2943,17 @@ def get_optional_enum[_EnumT: StrEnum]( domain=device["domain"], ) + if ( + shadowed_count := devices.count_shadowed_keys() + + deleted_devices.count_shadowed_keys() + ): + _LOGGER.info( + "Loaded %d identifiers/connections registered to multiple devices of " + "one config entry; they will be reconciled as integrations register " + "their devices", + shadowed_count, + ) + self.devices = devices self.deleted_devices = deleted_devices self._device_data = devices.data @@ -2779,11 +3023,17 @@ def _async_orphan_deleted_device( ) self.async_schedule_save() + @callback + def async_config_entry_unloaded(self, config_entry_id: str) -> None: + """Forget the live devices of a config entry that unloaded or failed setup.""" + self._live_device_ids.pop(config_entry_id, None) + @callback def async_clear_config_entry( self, config_entry_id: str, domain: str | None = None ) -> None: """Clear config entry from registry entries.""" + self._live_device_ids.pop(config_entry_id, None) domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() for device in self.devices.get_devices_for_config_entry_id(config_entry_id): diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index ed48c6952a1f77..95048ed8cfea52 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2,13 +2,13 @@ from collections.abc import Callable, Iterable from contextlib import AbstractContextManager, nullcontext -from datetime import datetime +from datetime import datetime, timedelta from functools import partial import json import pathlib import time from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, AsyncMock, patch import attr from freezegun.api import FrozenDateTimeFactory @@ -18,7 +18,7 @@ from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, ReleaseChannel -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -27,7 +27,17 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry, async_capture_events, flush_store +from tests.common import ( + MockConfigEntry, + MockModule, + async_capture_events, + async_fire_time_changed, + flush_store, + mock_config_flow, + mock_device_registry, + mock_integration, + mock_platform, +) def _get_device_for_config_entry( @@ -44,6 +54,29 @@ def _get_device_for_config_entry( return None +def _mock_deleted_device( + device_id: str, + config_entry_id: str, + identifiers: set[tuple[str, str]], +) -> dr.DeletedDeviceEntry: + """Create a deleted device entry for seeding stored registry states.""" + return dr.DeletedDeviceEntry( + area_id=None, + config_entry_id=config_entry_id, + config_subentry_id=None, + connections=set(), + created_at=utcnow(), + disabled_by=None, + id=device_id, + identifiers=identifiers, + labels=set(), + modified_at=utcnow(), + name_by_user=None, + orphaned_timestamp=None, + domain="test", + ) + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a mock config entry and add it to hass.""" @@ -7267,22 +7300,23 @@ async def test_device_registry_connections_collision( assert device2_refetched.id == device1_refetched.id assert len(device_registry.devices) == 2 - # Attempt to implicitly merge connection for device3 with the same - # connection that already exists in device1 - device4 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("bridgeid", "0123")}, - connections={ - (dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"), - (dr.CONNECTION_NETWORK_MAC, "none"), - }, - ) + # Implicitly merging a connection registered to another device of the same + # config entry raises + with pytest.raises(dr.DeviceInfoError, match="already registered"): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + connections={ + (dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE"), + (dr.CONNECTION_NETWORK_MAC, "none"), + }, + ) assert len(device_registry.devices) == 2 - assert device4.id in (device1.id, device3.id) device3_refetched = device_registry.async_get(device3.id) + assert device3_refetched.connections == set() device1_refetched = device_registry.async_get(device1.id) - assert not device1_refetched.connections.isdisjoint(device3_refetched.connections) + assert device1_refetched.connections == {(dr.CONNECTION_NETWORK_MAC, "none")} async def test_device_registry_identifiers_collision( @@ -7347,18 +7381,19 @@ async def test_device_registry_identifiers_collision( assert device2_refetched.id == device1_refetched.id assert len(device_registry.devices) == 2 - # Attempt to implicitly merge identifiers for device3 with the same - # connection that already exists in device1 - device4 = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("bridgeid", "4567"), ("bridgeid", "0123")}, - ) + # Implicitly merging an identifier registered to another device of the same + # config entry raises + with pytest.raises(dr.DeviceInfoError, match="already registered"): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "4567"), ("bridgeid", "0123")}, + ) assert len(device_registry.devices) == 2 - assert device4.id in (device1.id, device3.id) device3_refetched = device_registry.async_get(device3.id) + assert device3_refetched.identifiers == {("bridgeid", "4567")} device1_refetched = device_registry.async_get(device1.id) - assert not device1_refetched.identifiers.isdisjoint(device3_refetched.identifiers) + assert device1_refetched.identifiers == {("bridgeid", "0123")} async def test_device_registry_deleted_device_collision( @@ -7625,6 +7660,579 @@ async def test_remove_shadowed_collision_keeps_index_consistent( ) +async def test_remove_device_promotes_shadowed_duplicate( + hass: HomeAssistant, +) -> None: + """Removing the indexed holder of a duplicate key promotes the shadowed one.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + device_registry = mock_device_registry( + hass, + { + "shadowed": dr.DeviceEntry( + id="shadowed", + config_entry_id=entry.entry_id, + identifiers={("test", "shared")}, + ), + "winner": dr.DeviceEntry( + id="winner", + config_entry_id=entry.entry_id, + identifiers={("test", "shared")}, + ), + }, + ) + assert ( + device_registry.async_get_device(identifiers={("test", "shared")}).id + == "winner" + ) + + device_registry.async_remove_device("winner") + + assert ( + device_registry.async_get_device(identifiers={("test", "shared")}).id + == "shadowed" + ) + + +async def test_update_device_reindexes_shadowed_duplicate( + hass: HomeAssistant, +) -> None: + """Updating a shadowed duplicate re-indexes it, so it wins the lookups.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + device_registry = mock_device_registry( + hass, + { + "shadowed": dr.DeviceEntry( + id="shadowed", + config_entry_id=entry.entry_id, + identifiers={("test", "shared")}, + ), + "winner": dr.DeviceEntry( + id="winner", + config_entry_id=entry.entry_id, + identifiers={("test", "shared")}, + ), + }, + ) + assert ( + device_registry.async_get_device(identifiers={("test", "shared")}).id + == "winner" + ) + + # The updated shadowed duplicate takes over the lookup slot (last indexed wins) + device_registry.async_update_device("shadowed", name_by_user="renamed") + assert ( + device_registry.async_get_device(identifiers={("test", "shared")}).id + == "shadowed" + ) + + # An updated slot holder stays in the slot + device_registry.async_update_device("shadowed", name_by_user="renamed again") + assert ( + device_registry.async_get_device(identifiers={("test", "shared")}).id + == "shadowed" + ) + assert len(device_registry.devices) == 2 + + +@pytest.mark.parametrize( + ("device_extra", "stale_extra", "register_extra"), + [ + pytest.param( + {"identifiers": {("test", "device"), ("test", "shared")}}, + {"identifiers": {("test", "stale"), ("test", "shared")}}, + {"identifiers": {("test", "device")}}, + id="identifiers", + ), + pytest.param( + { + "identifiers": {("test", "device")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + }, + { + "identifiers": {("test", "stale")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + }, + { + "identifiers": {("test", "device")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + }, + id="connections", + ), + ], +) +async def test_legacy_duplicate_keys_stripped_on_registration( + hass: HomeAssistant, + hass_storage: dict[str, Any], + device_extra: dict[str, set[tuple[str, str]]], + stale_extra: dict[str, set[tuple[str, str]]], + register_extra: dict[str, set[tuple[str, str]]], +) -> None: + """Registering a device strips its keys from stale same-entry duplicates. + + The stale duplicate keeps its other keys; another config entry is not affected. + """ + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + other_entry = MockConfigEntry(domain="test") + other_entry.add_to_hass(hass) + shared_keys = device_extra.keys() & stale_extra.keys() + device_registry = mock_device_registry( + hass, + { + "device": dr.DeviceEntry( + id="device", config_entry_id=entry.entry_id, **device_extra + ), + "stale": dr.DeviceEntry( + id="stale", config_entry_id=entry.entry_id, **stale_extra + ), + "other": dr.DeviceEntry( + id="other", + config_entry_id=other_entry.entry_id, + **{key: device_extra[key] & stale_extra[key] for key in shared_keys}, + ), + }, + ) + + registered = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, **register_extra + ) + + assert registered.id == "device" + assert registered.identifiers == device_extra["identifiers"] + assert registered.connections == device_extra.get("connections", set()) + # The stale duplicate lost the shared keys but keeps its own + stale = device_registry.async_get("stale") + assert stale.identifiers == {("test", "stale")} + assert stale.connections == set() + # The registered device is reachable by the previously shared keys + assert ( + _get_device_for_config_entry( + device_registry, + entry.entry_id, + identifiers=device_extra["identifiers"], + connections=device_extra.get("connections"), + ).id + == "device" + ) + # A device of another config entry sharing a key is not affected + other = device_registry.async_get("other") + assert other.identifiers == device_extra["identifiers"] & stale_extra["identifiers"] + assert other.connections == device_extra.get( + "connections", set() + ) & stale_extra.get("connections", set()) + # Registering again is a no-op + registered = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, **register_extra + ) + assert registered.id == "device" + assert len(device_registry.devices) == 3 + # The stripped keys are persisted + await flush_store(device_registry._store) + stored_stale = next( + device + for device in hass_storage[dr.STORAGE_KEY]["data"]["devices"] + if device["id"] == "stale" + ) + assert {tuple(identifier) for identifier in stored_stale["identifiers"]} == { + ("test", "stale") + } + assert stored_stale["connections"] == [] + + +async def test_legacy_duplicate_fully_stripped_device_removed( + hass: HomeAssistant, +) -> None: + """A stale duplicate left without any keys is removed, also from deleted devices.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + device_registry = mock_device_registry( + hass, + { + "device": dr.DeviceEntry( + id="device", + config_entry_id=entry.entry_id, + identifiers={("test", "device"), ("test", "shared")}, + ), + "stale": dr.DeviceEntry( + id="stale", + config_entry_id=entry.entry_id, + identifiers={("test", "shared")}, + ), + }, + ) + + registered = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "device")} + ) + + assert registered.id == "device" + assert registered.identifiers == {("test", "device"), ("test", "shared")} + assert device_registry.async_get("stale") is None + assert "stale" not in device_registry.deleted_devices + assert ( + device_registry.async_get_device(identifiers={("test", "shared")}).id + == "device" + ) + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_loading_from_storage_with_legacy_duplicates( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Duplicates from an old store are tracked on load and reconciled on registration.""" + entry_id = mock_config_entry.entry_id + created_at = "2024-01-01T00:00:00+00:00" + + def _stored_device( + device_id: str, identifiers: list[list[str]], connections: list[list[str]] + ) -> dict[str, Any]: + return { + "area_id": None, + "config_entries": [entry_id], + "config_entries_subentries": {entry_id: [None]}, + "config_entry_id": entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, + "configuration_url": None, + "connections": connections, + "created_at": created_at, + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": device_id, + "identifiers": identifiers, + "labels": [], + "manufacturer": None, + "model": None, + "model_id": None, + "modified_at": created_at, + "name_by_user": None, + "name": None, + "primary_config_entry": entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + + hass_storage[dr.STORAGE_KEY] = { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "data": { + "devices": [ + _stored_device( + "old", + [["test", "shared"]], + [[dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"]], + ), + _stored_device( + "new", + [["test", "shared"], ["test", "own"]], + [[dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"]], + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + assert ( + "Loaded 2 identifiers/connections registered to multiple devices of one " + "config entry" in caplog.text + ) + # The last stored duplicate wins the lookups + assert registry.async_get_device(identifiers={("test", "shared")}).id == "new" + assert ( + registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")} + ).id + == "new" + ) + assert len(registry.devices) == 2 + + # Registration reconciles: the fully shadowed duplicate is removed + registered = registry.async_get_or_create( + config_entry_id=entry_id, + identifiers={("test", "shared")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + ) + assert registered.id == "new" + assert registry.async_get("old") is None + assert "old" not in registry.deleted_devices + + # The reconciled state is persisted + await flush_store(registry._store) + stored_devices = hass_storage[dr.STORAGE_KEY]["data"]["devices"] + assert [device["id"] for device in stored_devices] == ["new"] + assert hass_storage[dr.STORAGE_KEY]["data"]["deleted_devices"] == [] + + +async def test_registration_purges_same_entry_deleted_duplicates( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Registering a device purges same-entry deleted devices holding its keys. + + Whole records are purged, shadowed included; another config entry is not affected. + """ + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + other_entry = MockConfigEntry(domain="test") + other_entry.add_to_hass(hass) + device_registry = mock_device_registry( + hass, + { + "device": dr.DeviceEntry( + id="device", + config_entry_id=entry.entry_id, + identifiers={("test", "device"), ("test", "shared")}, + ), + }, + ) + device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + "deleted_shadowed", entry.entry_id, {("test", "shared"), ("test", "other")} + ) + device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + "deleted_winner", entry.entry_id, {("test", "shared")} + ) + device_registry.deleted_devices["deleted_other_entry"] = _mock_deleted_device( + "deleted_other_entry", other_entry.entry_id, {("test", "shared")} + ) + + registered = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "device")} + ) + + assert registered.id == "device" + assert "deleted_winner" not in device_registry.deleted_devices + assert "deleted_shadowed" not in device_registry.deleted_devices + assert "deleted_other_entry" in device_registry.deleted_devices + # The purge is persisted + await flush_store(device_registry._store) + assert [ + device["id"] + for device in hass_storage[dr.STORAGE_KEY]["data"]["deleted_devices"] + ] == ["deleted_other_entry"] + + +async def test_restore_purges_same_entry_deleted_duplicate( + hass: HomeAssistant, +) -> None: + """Restoring a deleted device purges its same-entry deleted duplicates.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + device_registry = mock_device_registry(hass) + device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + "deleted_shadowed", entry.entry_id, {("test", "shared")} + ) + device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + "deleted_winner", entry.entry_id, {("test", "shared")} + ) + + restored = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "shared")} + ) + + assert restored.id == "deleted_winner" + assert "deleted_winner" not in device_registry.deleted_devices + assert "deleted_shadowed" not in device_registry.deleted_devices + assert len(device_registry.devices) == 1 + + +async def test_add_identifier_prunes_shadowed_deleted_duplicates( + hass: HomeAssistant, +) -> None: + """Gaining an identifier removes all matching same-entry deleted devices. + + Whole records are removed, shadowed duplicates included. + """ + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + device_registry = mock_device_registry(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "device")} + ) + device_registry.deleted_devices["deleted_shadowed"] = _mock_deleted_device( + "deleted_shadowed", entry.entry_id, {("test", "shared")} + ) + device_registry.deleted_devices["deleted_winner"] = _mock_deleted_device( + "deleted_winner", entry.entry_id, {("test", "shared"), ("test", "other")} + ) + + device_registry.async_update_device( + device.id, merge_identifiers={("test", "shared")} + ) + + assert "deleted_winner" not in device_registry.deleted_devices + assert "deleted_shadowed" not in device_registry.deleted_devices + + +async def test_via_device_id_to_removed_stale_duplicate_raises( + hass: HomeAssistant, +) -> None: + """A via_device_id to a stale duplicate removed by reconciliation raises.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + device_registry = mock_device_registry( + hass, + { + "device": dr.DeviceEntry( + id="device", + config_entry_id=entry.entry_id, + identifiers={("test", "device"), ("test", "shared")}, + ), + "stale": dr.DeviceEntry( + id="stale", + config_entry_id=entry.entry_id, + identifiers={("test", "shared")}, + ), + }, + ) + + with pytest.raises(dr.DeviceInfoError, match="not a registered device id"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "device")}, + via_device_id="stale", + ) + assert device_registry.async_get("stale") is None + + +async def test_key_collision_reconciled_after_config_entry_reload( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """A key moved between devices raises while loaded and reconciles after reload.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "test.config_flow", None) + + class MockFlow(config_entries.ConfigFlow): + """Test flow.""" + + with mock_config_flow("test", MockFlow): + assert await hass.config_entries.async_setup(entry.entry_id) + + connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device_a = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "device_a")}, + connections={connection}, + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "device_b")}, + ) + + # Both devices are registered this setup session, so moving the key raises + with pytest.raises(dr.DeviceInfoError, match="already registered"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "device_b")}, + connections={connection}, + ) + device_a_refetched = device_registry.async_get(device_a.id) + assert device_a_refetched is not None + assert device_a_refetched.connections == {connection} + + assert await hass.config_entries.async_unload(entry.entry_id) + assert await hass.config_entries.async_setup(entry.entry_id) + + # In the new setup session the registering device is authoritative + device_b_refetched = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "device_b")}, + connections={connection}, + ) + assert device_b_refetched.id == device_b.id + assert device_b_refetched.connections == {connection} + device_a_refetched = device_registry.async_get(device_a.id) + assert device_a_refetched is not None + assert device_a_refetched.connections == set() + + +async def test_key_collision_reconciled_after_setup_retry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """A key moved between devices reconciles on the retry after a failed setup.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + + connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + + async def first_attempt( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry + ) -> bool: + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device_a")}, + connections={connection}, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device_b")}, + ) + raise ConfigEntryNotReady + + async def second_attempt( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry + ) -> bool: + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device_b")}, + connections={connection}, + ) + return True + + attempts = [first_attempt, second_attempt] + + async def async_setup_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry + ) -> bool: + return await attempts.pop(0)(hass, config_entry) + + mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + class MockFlow(config_entries.ConfigFlow): + """Test flow.""" + + with mock_config_flow("test", MockFlow): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + # The retry reconciled the key away from the failed attempt's device + assert entry.state is config_entries.ConfigEntryState.LOADED + device_b = device_registry.async_get_device(identifiers={("test", "device_b")}) + assert device_b is not None + assert device_b.connections == {connection} + device_a = device_registry.async_get_device(identifiers={("test", "device_a")}) + assert device_a is not None + assert device_a.connections == set() + + @pytest.mark.parametrize( ("identity", "merge_kwarg", "merge_extra", "error"), [ From b955e776e786169d3fbfdaed86d3d800dfdcb994 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jul 2026 04:36:09 -1000 Subject: [PATCH 15/15] Cache split_tests pytest --collect-only results between CI runs (#171780) Co-authored-by: Robert Resch --- .github/workflows/ci.yaml | 39 +- script/split_tests.py | 459 ++++++++++++++++++--- tests/script/test_split_tests.py | 659 +++++++++++++++++++++++++++++++ 3 files changed, 1094 insertions(+), 63 deletions(-) create mode 100644 tests/script/test_split_tests.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index eb880a6ca00a3a..bead8a7590d589 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -734,12 +734,49 @@ jobs: python-cache-key: ${{ needs.info.outputs.python_cache_key }} uv-cache-dir: ${{ env.UV_CACHE_DIR }} apt-cache-version: ${{ env.APT_CACHE_VERSION }} + - name: Restore pytest test counts cache + id: cache-pytest-counts + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: pytest_test_counts.json + # Primary key is a sentinel; restore-keys pick the most recent + # prefix match since the real (content-addressed) key isn't + # known until split_tests.py runs below. + key: >- + pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ + steps.python.outputs.python-version }}-${{ + needs.info.outputs.python_cache_key }}-restore-sentinel + restore-keys: | + pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}- - name: Run split_tests.py env: TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }} run: | . venv/bin/activate - python -m script.split_tests ${TEST_GROUP_COUNT} tests + python -m script.split_tests \ + --cache pytest_test_counts.json \ + ${TEST_GROUP_COUNT} tests + - name: Hash pytest test counts cache + id: cache-pytest-counts-hash + run: | + echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \ + >> "$GITHUB_OUTPUT" + - name: Save pytest test counts cache + # Content-addressed key: identical content reuses the same entry. + # Skip the save when the restore already matched that hash. + if: >- + !endsWith( + steps.cache-pytest-counts.outputs.cache-matched-key, + steps.cache-pytest-counts-hash.outputs.hash + ) + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: pytest_test_counts.json + key: >- + pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ + steps.python.outputs.python-version }}-${{ + needs.info.outputs.python_cache_key }}-${{ + steps.cache-pytest-counts-hash.outputs.hash }} - name: Upload pytest_buckets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/script/split_tests.py b/script/split_tests.py index 5b770fc913a31d..4e36614bdd6213 100755 --- a/script/split_tests.py +++ b/script/split_tests.py @@ -2,9 +2,14 @@ """Helper script to split test into n buckets.""" import argparse +from collections.abc import Iterator from concurrent.futures import ProcessPoolExecutor -from dataclasses import dataclass, field +from contextlib import suppress +from dataclasses import dataclass, field, replace +import hashlib +import json from math import ceil +from operator import attrgetter, itemgetter import os from pathlib import Path import subprocess @@ -15,13 +20,21 @@ # place to subdivide to keep each pytest invocation roughly equal in size. _FAN_OUT_DIRS: Final = frozenset({"components"}) +# Cache file format version; bump on any incompatible schema change so old +# caches are ignored rather than misread. +_CACHE_VERSION: Final = 1 + +# Fall back from file-level to directory-level pytest collection when +# misses make up more than this fraction of the tree; past that point +# the per-file argv overhead pytest pays outweighs the cost of letting +# it re-walk dirs and re-collect the hits. +_DIR_LEVEL_MISS_RATIO: Final = 0.3 + class Bucket: """Class to hold bucket.""" - def __init__( - self, - ): + def __init__(self) -> None: """Initialize bucket.""" self.total_tests = 0 self._paths: list[str] = [] @@ -47,43 +60,55 @@ def __init__(self, tests_per_bucket: int, bucket_count: int) -> None: self._buckets: list[Bucket] = [Bucket() for _ in range(bucket_count)] def split_tests(self, test_folder: TestFolder) -> None: - """Split tests into buckets.""" + """Place atomic units via best-fit; oversized ones go to the smallest bucket.""" digits = len(str(test_folder.total_tests)) - sorted_tests = sorted( - test_folder.get_all_flatten(), reverse=True, key=lambda x: x.total_tests - ) - for tests in sorted_tests: - if tests.added_to_bucket: - # Already added to bucket - continue + by_load = attrgetter("total_tests") + units = sorted(self._atomic_units(test_folder), key=itemgetter(0), reverse=True) + for size, items in units: + fits = [ + b + for b in self._buckets + if b.total_tests + size <= self._tests_per_bucket + ] + bucket = max(fits, key=by_load) if fits else min(self._buckets, key=by_load) + for item in items: + tag = " (same bucket)" if item is not items[0] else "" + print(f"{item.total_tests:>{digits}} tests in {item.path}{tag}") + bucket.add(item) - print(f"{tests.total_tests:>{digits}} tests in {tests.path}") - smallest_bucket = min(self._buckets, key=lambda x: x.total_tests) - is_file = isinstance(tests, TestFile) - if ( - smallest_bucket.total_tests + tests.total_tests < self._tests_per_bucket - ) or is_file: - smallest_bucket.add(tests) - # Ensure all files from the same folder are in the same bucket - # to ensure that syrupy correctly identifies unused snapshots - if is_file: - for other_test in tests.parent.children.values(): - if other_test is tests or isinstance(other_test, TestFolder): - continue - print( - f"{other_test.total_tests:>{digits}}" - f" tests in {other_test.path}" - " (same bucket)" - ) - smallest_bucket.add(other_test) - - # verify that all tests are added to a bucket if not test_folder.added_to_bucket: raise ValueError("Not all tests are added to a bucket") - def create_ouput_file(self) -> None: + def _atomic_units( + self, folder: TestFolder + ) -> Iterator[tuple[int, list[TestFolder | TestFile]]]: + """Yield ``(size, items)`` placement units. + + A folder that fits is one unit; otherwise same-dir files form + a unit only when the folder has syrupy snapshots, else each + file stands alone. Sub-folders recurse independently. + """ + if folder.total_tests <= self._tests_per_bucket: + yield folder.total_tests, [folder] + return + + sibling_files = [c for c in folder.children.values() if isinstance(c, TestFile)] + if sibling_files: + if _has_snapshots(folder.path): + yield ( + sum(f.total_tests for f in sibling_files), + list(sibling_files), + ) + else: + for file in sibling_files: + yield file.total_tests, [file] + for child in folder.children.values(): + if isinstance(child, TestFolder): + yield from self._atomic_units(child) + + def create_output_file(self) -> None: """Create output file.""" - with Path("pytest_buckets.txt").open("w") as file: + with Path("pytest_buckets.txt").open("w", encoding="utf-8") as file: for idx, bucket in enumerate(self._buckets): print(f"Bucket {idx + 1} has {bucket.total_tests} tests") file.write(bucket.get_paths_line()) @@ -170,6 +195,15 @@ def get_all_flatten(self) -> list[TestFolder | TestFile]: return result +def _has_snapshots(folder_path: Path) -> bool: + """Return True when ``folder_path/snapshots`` holds ``.ambr`` files. + + Same-dir tests must share a pytest run so syrupy can spot unused + snapshots; without snapshots that constraint doesn't apply. + """ + return any((folder_path / "snapshots").glob("*.ambr")) + + def _collect_batch(paths: list[Path]) -> tuple[str, str, int]: """Run pytest --collect-only on a batch of paths.""" result = subprocess.run( @@ -216,44 +250,339 @@ def _enumerate_batch_paths(path: Path) -> list[Path]: return paths -def collect_tests(path: Path) -> TestFolder: - """Collect all tests.""" - batch_paths = _enumerate_batch_paths(path) - if not batch_paths: - print(f"No eligible test paths found under {path}") - sys.exit(1) - workers = min(len(batch_paths), os.cpu_count() or 1) or 1 - # Round-robin chunking keeps batches roughly balanced when path - # ordering correlates with test size. - batches = [batch_paths[i::workers] for i in range(workers)] +def _hash_file(path: Path) -> str: + """Return a short content hash for ``path``.""" + return hashlib.sha256(path.read_bytes()).hexdigest()[:16] + +def _walk_test_tree(root: Path) -> tuple[list[Path], list[Path]]: + """Walk ``root`` once and return (test files, fixture files). + + Fixtures are every non-``test_*.py`` ``.py``: conftests and helpers + like ``common.py`` that drive parametrize imports. Uses ``os.walk`` + (~2x faster than ``Path.rglob`` on this tree) and prunes ``.``/``_`` + subdirs. + """ + test_files: list[Path] = [] + fixtures: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if not d.startswith((".", "_"))] + base = Path(dirpath) + for name in filenames: + if not name.endswith(".py"): + continue + if name.startswith("test_"): + test_files.append(base / name) + else: + fixtures.append(base / name) + test_files.sort() + fixtures.sort() + return test_files, fixtures + + +_PROJECT_ROOT_MARKER: Final = "pyproject.toml" + + +def _find_ancestor_fixtures(root: Path) -> list[Path]: + """Return non-``test_*.py`` Python files above ``root``, up to the project root. + + Includes conftests and helper modules (eg ``common.py``); subtree + runs need both so shared ancestor helpers like + ``tests/components/common.py`` still invalidate descendants. + Stops at the first ancestor containing ``pyproject.toml`` so we + don't read unrelated ``.py`` files outside the repo or trip on + dirs we can't list. + """ + fixtures: list[Path] = [] + current = root.resolve().parent + while True: + with suppress(OSError): + fixtures.extend( + entry + for entry in current.glob("*.py") + if not entry.name.startswith("test_") + ) + if (current / _PROJECT_ROOT_MARKER).exists(): + break + if current == current.parent: + break + current = current.parent + return fixtures + + +def _build_fixtures_by_dir( + root: Path, descendants: list[Path] +) -> dict[Path, list[Path]]: + """Bucket descendants plus ancestor fixtures by resolved parent dir.""" + by_dir: dict[Path, list[Path]] = {} + for fixture in (*_find_ancestor_fixtures(root), *descendants): + by_dir.setdefault(fixture.parent.resolve(), []).append(fixture) + return by_dir + + +def _file_fixture_hash( + test_file: Path, + root: Path, + fixtures_by_dir: dict[Path, list[Path]], + blob_cache: dict[Path, bytes] | None = None, + dir_cache: dict[Path, str] | None = None, +) -> str: + """Hash every ``.py`` fixture on the test file's ancestor path. + + Catches conftests and helper modules (``common.py`` etc.) at any + level so parametrize imports from shared helpers invalidate + descendants, while sibling subtrees stay warm. Pass shared + ``blob_cache``/``dir_cache`` dicts to memoize across many files. + """ + test_dir = test_file.parent.resolve() + if dir_cache is not None and (cached := dir_cache.get(test_dir)) is not None: + return cached + relevant: list[Path] = [] + current = test_dir + while True: + relevant.extend(fixtures_by_dir.get(current, ())) + parent = current.parent + if parent == current: + break + current = parent + relevant.sort() + digest = hashlib.sha256() + for fixture in relevant: + blob = blob_cache.get(fixture) if blob_cache is not None else None + if blob is None: + # relpath keeps the hash machine-stable across ancestor paths. + blob = ( + os.path.relpath(fixture, root).encode() + + b"\0" + + fixture.read_bytes() + + b"\0" + ) + if blob_cache is not None: + blob_cache[fixture] = blob + digest.update(blob) + result = digest.hexdigest()[:16] + if dir_cache is not None: + dir_cache[test_dir] = result + return result + + +@dataclass +class _CacheEntry: + """Cached test count plus its scope hash for a single file.""" + + hash: str + fixture_hash: str + count: int + + +@dataclass +class _Cache: + """Mapping of test file path → cached entry.""" + + entries: dict[str, _CacheEntry] + + @classmethod + def load(cls, path: Path) -> _Cache: + """Load cache; any drift (missing, bad, version, malformed) returns empty.""" + try: + raw = json.loads(path.read_bytes()) + except OSError, ValueError: + raw = None + if not ( + isinstance(raw, dict) + and raw.get("version") == _CACHE_VERSION + and isinstance(raw.get("files"), dict) + ): + return cls(entries={}) + entries: dict[str, _CacheEntry] = {} + for key, value in raw["files"].items(): + if not isinstance(value, dict): + continue + hash_value = value.get("hash") + fixture_hash = value.get("fixture_hash") + count = value.get("count") + if ( + not isinstance(hash_value, str) + or not isinstance(fixture_hash, str) + or not isinstance(count, int) + or count < 0 + ): + continue + entries[key] = _CacheEntry( + hash=hash_value, fixture_hash=fixture_hash, count=count + ) + return cls(entries=entries) + + def save(self, path: Path) -> None: + """Write the cache to ``path``, creating parent dirs as needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "version": _CACHE_VERSION, + "files": { + key: { + "hash": entry.hash, + "fixture_hash": entry.fixture_hash, + "count": entry.count, + } + for key, entry in sorted(self.entries.items()) + }, + }, + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) + + +def _resolve_entries( + test_files: list[Path], + cache: _Cache, + root: Path, + fixtures_by_dir: dict[Path, list[Path]], +) -> tuple[dict[Path, _CacheEntry], list[Path]]: + """Build an entry for every file; return ``(entries, misses)``. + + Hits reuse the stored entry; misses get fresh hashes with a + count=0 placeholder for the caller to fill in after pytest runs. + Shared caches memoize fixture blobs and per-dir hashes so each + fixture file is read once and each unique dir hashed once. + """ + blob_cache: dict[Path, bytes] = {} + dir_cache: dict[Path, str] = {} + entries: dict[Path, _CacheEntry] = {} + misses: list[Path] = [] + for file in test_files: + file_hash = _hash_file(file) + fixture_hash = _file_fixture_hash( + file, root, fixtures_by_dir, blob_cache, dir_cache + ) + cached = cache.entries.get(str(file.relative_to(root))) + if ( + cached is not None + and cached.hash == file_hash + and cached.fixture_hash == fixture_hash + ): + entries[file] = cached + else: + entries[file] = _CacheEntry( + hash=file_hash, fixture_hash=fixture_hash, count=0 + ) + misses.append(file) + return entries, misses + + +def _run_collect_batches(paths: list[Path]) -> list[tuple[str, str, int]]: + """Run pytest --collect-only across ``paths`` using a process pool.""" + workers = min(len(paths), os.cpu_count() or 1) or 1 + batches = [paths[i::workers] for i in range(workers)] if workers == 1: - results = [_collect_batch(batches[0])] - else: - with ProcessPoolExecutor(max_workers=workers) as executor: - results = list(executor.map(_collect_batch, batches)) + return [_collect_batch(batches[0])] + with ProcessPoolExecutor(max_workers=workers) as executor: + return list(executor.map(_collect_batch, batches)) + + +def _parse_collect_output(stdout: str) -> dict[Path, int]: + """Parse ``pytest --collect-only -qq`` output into ``{path: count}``.""" + counts: dict[Path, int] = {} + for line in stdout.splitlines(): + if not line.strip(): + continue + file_path, _, total_tests = line.partition(": ") + if not file_path or not total_tests: + raise ValueError(f"Unexpected line: {line}") + counts[Path(file_path)] = int(total_tests) + return counts - folder = TestFolder(path) - for stdout, stderr, returncode in results: + +def _run_pytest_collect(paths: list[Path]) -> dict[Path, int]: + """Run pytest --collect-only across ``paths`` and parse the output.""" + counts: dict[Path, int] = {} + for stdout, stderr, returncode in _run_collect_batches(paths): if returncode != 0: print("Failed to collect tests:") print(stderr) print(stdout) sys.exit(1) - for line in stdout.splitlines(): - if not line.strip(): - continue - file_path, _, total_tests = line.partition(": ") - if not file_path or not total_tests: - print(f"Unexpected line: {line}") - sys.exit(1) + # Surface stderr from successful runs too; pytest puts deprecation + # and import warnings here that would otherwise vanish. + if stderr.strip(): + sys.stderr.write(stderr) + try: + counts.update(_parse_collect_output(stdout)) + except ValueError as err: + print(err) + sys.exit(1) + return counts - file = TestFile(int(total_tests), Path(file_path)) - folder.add_test_file(file) +def _build_folder(root: Path, counts: dict[Path, int]) -> TestFolder: + """Build a ``TestFolder`` from ``{path: count}``; zero-count files are skipped.""" + folder = TestFolder(root) + for file_path, count in counts.items(): + if count: + folder.add_test_file(TestFile(count, file_path)) return folder +def _exit_if_empty(paths: list[Path], root: Path) -> None: + """Exit with a clear message when no eligible test paths were found.""" + if not paths: + print(f"No eligible test paths found under {root}") + sys.exit(1) + + +def _collect_tests_uncached(path: Path) -> TestFolder: + """Hand pytest the top-level dirs; the pre-cache path when ``--cache`` is unset.""" + batch_paths = _enumerate_batch_paths(path) + _exit_if_empty(batch_paths, path) + return _build_folder(path, _run_pytest_collect(batch_paths)) + + +def _collect_tests_cached(path: Path, cache_path: Path) -> TestFolder: + """Collect tests using an on-disk cache for incremental updates.""" + all_test_files, fixtures = _walk_test_tree(path) + _exit_if_empty(all_test_files, path) + + fixtures_by_dir = _build_fixtures_by_dir(path, fixtures) + cache = _Cache.load(cache_path) + entries, misses = _resolve_entries(all_test_files, cache, path, fixtures_by_dir) + hits = len(all_test_files) - len(misses) + print(f"Cache: {hits} hits / {len(misses)} misses / {len(all_test_files)} total") + + if misses: + # Past _DIR_LEVEL_MISS_RATIO the per-file argv overhead beats + # re-walking the dirs, so fall back to dir-level collection. + if not hits or len(misses) > len(all_test_files) * _DIR_LEVEL_MISS_RATIO: + collect_paths = _enumerate_batch_paths(path) + else: + collect_paths = misses + new_counts = _run_pytest_collect(collect_paths) + # Files pytest returned no count for stay at 0; cached so they + # aren't re-collected next run. + for file in misses: + entries[file] = replace(entries[file], count=new_counts.get(file, 0)) + + _Cache(entries={str(f.relative_to(path)): e for f, e in entries.items()}).save( + cache_path + ) + return _build_folder(path, {f: e.count for f, e in entries.items()}) + + +def collect_tests(path: Path, cache_path: Path | None = None) -> TestFolder: + """Collect all tests, using an on-disk cache when ``cache_path`` is set.""" + if path.is_file(): + # TestFolder requires a directory root; a single file has no + # parent to anchor against and CI never invokes us this way. + print(f"Expected a directory, got file: {path}") + sys.exit(1) + if cache_path is None: + return _collect_tests_uncached(path) + return _collect_tests_cached(path, cache_path) + + def main() -> None: """Execute script.""" parser = argparse.ArgumentParser(description="Split tests into n buckets.") @@ -276,11 +605,17 @@ def check_greater_0(value: str) -> int: help="Path to the test files to split into buckets", type=Path, ) + parser.add_argument( + "--cache", + help="Path to a JSON file used to cache per-file test counts", + type=Path, + default=None, + ) arguments = parser.parse_args() print("Collecting tests...") - tests = collect_tests(arguments.path) + tests = collect_tests(arguments.path, arguments.cache) tests_per_bucket = ceil(tests.total_tests / arguments.bucket_count) bucket_holder = BucketHolder(tests_per_bucket, arguments.bucket_count) @@ -290,7 +625,7 @@ def check_greater_0(value: str) -> int: print(f"Total tests: {tests.total_tests}") print(f"Estimated tests per bucket: {tests_per_bucket}") - bucket_holder.create_ouput_file() + bucket_holder.create_output_file() if __name__ == "__main__": diff --git a/tests/script/test_split_tests.py b/tests/script/test_split_tests.py new file mode 100644 index 00000000000000..007c0ad5a8bcd1 --- /dev/null +++ b/tests/script/test_split_tests.py @@ -0,0 +1,659 @@ +"""Tests for the split_tests cache logic.""" + +from collections.abc import Callable +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from script import split_tests + + +@pytest.fixture +def tree(tmp_path: Path) -> Path: + """Build a tree: root conftest, two integrations, a ``common.py`` helper.""" + # Bound the ancestor-fixture walk so it doesn't escape tmp_path. + (tmp_path / "pyproject.toml").write_text("") + (tmp_path / "conftest.py").write_text("# tests/conftest.py\n") + (tmp_path / "common.py").write_text("# helper module\n") + + alpha_dir = tmp_path / "components" / "alpha" + alpha_dir.mkdir(parents=True) + (alpha_dir / "conftest.py").write_text("# alpha conftest\n") + (alpha_dir / "test_one.py").write_text("def test_a():\n pass\n") + (alpha_dir / "test_two.py").write_text("def test_b():\n pass\n") + + beta_dir = tmp_path / "components" / "beta" + beta_dir.mkdir() + (beta_dir / "test_x.py").write_text("def test_x():\n pass\n") + + return tmp_path + + +def test_iter_eligible_children_filters_helpers(tree: Path) -> None: + """Helper files like conftest.py and common.py are not collection targets.""" + children = split_tests._iter_eligible_children(tree) + names = {p.name for p in children} + assert "common.py" not in names + assert "conftest.py" not in names + # components/ is a dir, gets included. + assert "components" in names + + +def test_enumerate_batch_paths_fans_out_components(tree: Path) -> None: + """tests/components fans out one level deeper into per-integration paths.""" + paths = split_tests._enumerate_batch_paths(tree) + rel = {p.relative_to(tree).as_posix() for p in paths} + assert rel == {"components/beta", "components/alpha"} + + +def test_enumerate_batch_paths_for_single_file(tmp_path: Path) -> None: + """A test file passed directly is returned as-is.""" + file = tmp_path / "test_solo.py" + file.write_text("def test_x(): pass\n") + assert split_tests._enumerate_batch_paths(file) == [file] + + +def _fixture_hash_for(tree: Path, file: Path) -> str: + """Compute the fixture scope hash for ``file`` rooted at ``tree``.""" + _, fixtures = split_tests._walk_test_tree(tree) + fixtures_by_dir = split_tests._build_fixtures_by_dir(tree, fixtures) + return split_tests._file_fixture_hash(file, tree, fixtures_by_dir) + + +def _prime_cache( + cache_path: Path, + tree: Path, + hits: dict[Path, int] | None = None, + extra_entries: dict[str, split_tests._CacheEntry] | None = None, +) -> None: + """Save a cache for ``tree`` keyed on real file and fixture hashes. + + ``hits`` maps file → cached count (hashed for real, so the next + run resolves as a hit). ``extra_entries`` injects raw entries + whose path may not exist on disk (eg ghost files). + """ + entries: dict[str, split_tests._CacheEntry] = { + str(file.relative_to(tree)): split_tests._CacheEntry( + hash=split_tests._hash_file(file), + fixture_hash=_fixture_hash_for(tree, file), + count=count, + ) + for file, count in (hits or {}).items() + } + if extra_entries: + entries.update(extra_entries) + split_tests._Cache(entries=entries).save(cache_path) + + +def _echo_one_test_each( + skip: set[Path] | None = None, +) -> Callable[[list[Path]], list[tuple[str, str, int]]]: + """Fake ``_run_collect_batches``: 1 test per path; ``skip`` paths drop out.""" + skip = skip or set() + + def fake(paths: list[Path]) -> list[tuple[str, str, int]]: + emitted = [p for p in paths if p not in skip] + return [("\n".join(f"{p}: 1" for p in emitted) + "\n", "", 0)] + + return fake + + +def test_file_fixture_hash_changes_when_ancestor_conftest_changes(tree: Path) -> None: + """A conftest edit in the file's ancestor chain busts that file's hash.""" + alpha_one = tree / "components" / "alpha" / "test_one.py" + before = _fixture_hash_for(tree, alpha_one) + # Same-dir conftest is an ancestor of alpha_one. + (tree / "components" / "alpha" / "conftest.py").write_text("# changed\n") + after = _fixture_hash_for(tree, alpha_one) + assert before != after + + +def test_file_fixture_hash_changes_when_same_dir_helper_changes(tree: Path) -> None: + """A non-conftest helper in the same dir busts the file's hash.""" + alpha_dir = tree / "components" / "alpha" + (alpha_dir / "common.py").write_text("# helper v1\n") + alpha_one = alpha_dir / "test_one.py" + before = _fixture_hash_for(tree, alpha_one) + (alpha_dir / "common.py").write_text("# helper v2\n") + after = _fixture_hash_for(tree, alpha_one) + assert before != after + + +def test_file_fixture_hash_isolated_from_sibling_dir(tree: Path) -> None: + """A helper change in a sibling subtree leaves this file's hash alone.""" + alpha_one = tree / "components" / "alpha" / "test_one.py" + before = _fixture_hash_for(tree, alpha_one) + # beta is a sibling of alpha (not an ancestor), so its helper edit + # must not affect alpha_one's fixture hash. + (tree / "components" / "beta" / "common.py").write_text("# beta v2\n") + after = _fixture_hash_for(tree, alpha_one) + assert before == after + + +def test_file_fixture_hash_changes_when_ancestor_helper_changes(tree: Path) -> None: + """A helper edit anywhere on the ancestor path busts the file's hash. + + Test files often import VALUES for ``@pytest.mark.parametrize`` from + shared helpers like ``tests/components/common.py``; any ancestor + ``.py`` change has to invalidate descendants so cached counts don't + drift after edits to those sources. + """ + alpha_one = tree / "components" / "alpha" / "test_one.py" + # Seed a shared helper one level up from alpha. + components_common = tree / "components" / "common.py" + components_common.write_text("# helper v1\n") + before = _fixture_hash_for(tree, alpha_one) + components_common.write_text("# helper v2\n") + after = _fixture_hash_for(tree, alpha_one) + assert before != after + + +def test_file_fixture_hash_stable_for_test_changes(tree: Path) -> None: + """Test-file edits do not invalidate the file's fixture hash.""" + alpha_one = tree / "components" / "alpha" / "test_one.py" + before = _fixture_hash_for(tree, alpha_one) + alpha_one.write_text("def test_a():\n pass\n\ndef test_c():\n pass\n") + after = _fixture_hash_for(tree, alpha_one) + assert before == after + + +def test_find_ancestor_fixtures_stops_at_project_root(tmp_path: Path) -> None: + """A project-root marker bounds the ancestor walk.""" + project = tmp_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text("") + (project / "common.py").write_text("# included\n") + nested = project / "tests" / "x" + nested.mkdir(parents=True) + # Above the project root: must NOT be picked up. + (tmp_path / "outside.py").write_text("# excluded\n") + + found = {p.name for p in split_tests._find_ancestor_fixtures(nested)} + assert "common.py" in found + assert "outside.py" not in found + + +def test_find_ancestor_fixtures_walks_through_gaps(tmp_path: Path) -> None: + """Ancestor conftests + helpers are collected across intermediate gaps.""" + (tmp_path / "pyproject.toml").write_text("") # bound the walk + nested = tmp_path / "a" / "b" / "c" + nested.mkdir(parents=True) + # ``a/b`` has no fixtures, but ``a`` has both a conftest and a helper. + (tmp_path / "a" / "conftest.py").write_text("# a\n") + (tmp_path / "a" / "common.py").write_text("# a helper\n") + (tmp_path / "a" / "b" / "c" / "conftest.py").write_text("# c\n") + + found = { + p.relative_to(tmp_path).as_posix() + for p in split_tests._find_ancestor_fixtures(nested) + } + # The walk starts at ``nested.parent`` (a/b); a/b/c/conftest.py is + # not an ancestor. Both ``a/conftest.py`` and ``a/common.py`` must + # be found despite a/b having no fixtures of its own. + assert "a/conftest.py" in found + assert "a/common.py" in found + assert "a/b/c/conftest.py" not in found + + +def test_file_fixture_hash_picks_up_ancestor_helper_above_root( + tmp_path: Path, +) -> None: + """An ancestor non-conftest helper above root still busts descendant hashes. + + A subtree run on ``components/`` must still invalidate when a shared + helper one level up (eg ``tests/components/common.py``) changes. + """ + (tmp_path / "pyproject.toml").write_text("") # bound the walk + (tmp_path / "common.py").write_text("# v1\n") + subtree = tmp_path / "components" + subtree.mkdir() + test_file = subtree / "test_x.py" + test_file.write_text("def test_x(): pass\n") + + before = _fixture_hash_for(subtree, test_file) + (tmp_path / "common.py").write_text("# v2\n") + after = _fixture_hash_for(subtree, test_file) + assert before != after + + +def test_file_fixture_hash_picks_up_ancestor_conftest_across_gap( + tmp_path: Path, +) -> None: + """An ancestor conftest across a gap still busts the descendant's hash.""" + (tmp_path / "pyproject.toml").write_text("") # bound the walk + nested = tmp_path / "a" / "b" + nested.mkdir(parents=True) + (tmp_path / "a" / "conftest.py").write_text("# v1\n") + test_file = nested / "test_x.py" + test_file.write_text("def test_x(): pass\n") + + before = _fixture_hash_for(nested, test_file) + (tmp_path / "a" / "conftest.py").write_text("# v2\n") + after = _fixture_hash_for(nested, test_file) + assert before != after + + +def test_file_fixture_hash_includes_ancestor_above_root(tmp_path: Path) -> None: + """An ancestor conftest above root must still scope a subtree file.""" + (tmp_path / "pyproject.toml").write_text("") # bound the walk + (tmp_path / "conftest.py").write_text("# parent\n") + subtree = tmp_path / "components" + subtree.mkdir() + test_file = subtree / "test_x.py" + test_file.write_text("def test_x(): pass\n") + + before = _fixture_hash_for(subtree, test_file) + (tmp_path / "conftest.py").write_text("# parent changed\n") + after = _fixture_hash_for(subtree, test_file) + assert before != after + + +def test_walk_test_tree_separates_tests_from_fixtures(tree: Path) -> None: + """The walker returns test_*.py files and every other .py as fixtures.""" + test_files, fixtures = split_tests._walk_test_tree(tree) + test_names = {p.name for p in test_files} + fixture_paths = {p.relative_to(tree).as_posix() for p in fixtures} + assert test_names == {"test_one.py", "test_two.py", "test_x.py"} + assert fixture_paths == { + "conftest.py", + "common.py", + "components/alpha/conftest.py", + } + + +def test_walk_test_tree_skips_hidden_and_dunder_dirs(tmp_path: Path) -> None: + """Hidden/dunder directories are pruned from the walk.""" + (tmp_path / "__pycache__").mkdir() + (tmp_path / "__pycache__" / "test_ghost.py").write_text("def test_g(): pass\n") + (tmp_path / ".hidden").mkdir() + (tmp_path / ".hidden" / "test_invisible.py").write_text("def test_h(): pass\n") + (tmp_path / "test_real.py").write_text("def test_r(): pass\n") + + test_files, _ = split_tests._walk_test_tree(tmp_path) + assert {p.name for p in test_files} == {"test_real.py"} + + +def test_collect_tests_rejects_single_file_root(tmp_path: Path) -> None: + """A file root has no parent to anchor a TestFolder, so we exit early.""" + file = tmp_path / "test_solo.py" + file.write_text("def test_x(): pass\n") + with pytest.raises(SystemExit): + split_tests.collect_tests(file) + + +def test_cache_roundtrip(tmp_path: Path) -> None: + """A cache survives save → load.""" + cache_path = tmp_path / "cache.json" + cache = split_tests._Cache( + entries={ + "tests/alpha/test_a.py": split_tests._CacheEntry( + hash="h1", fixture_hash="f1", count=5 + ) + }, + ) + cache.save(cache_path) + loaded = split_tests._Cache.load(cache_path) + assert loaded.entries == cache.entries + + +def test_cache_load_missing_returns_empty(tmp_path: Path) -> None: + """A missing cache file degrades gracefully to an empty cache.""" + cache = split_tests._Cache.load(tmp_path / "missing.json") + assert cache.entries == {} + + +def test_cache_load_invalid_json_returns_empty(tmp_path: Path) -> None: + """Corrupt JSON is treated as a cache miss instead of crashing.""" + path = tmp_path / "broken.json" + path.write_text("{not json") + cache = split_tests._Cache.load(path) + assert cache.entries == {} + + +def test_cache_load_wrong_version_returns_empty(tmp_path: Path) -> None: + """An older cache schema is discarded rather than misread.""" + path = tmp_path / "old.json" + path.write_text(json.dumps({"version": 0, "files": {}})) + cache = split_tests._Cache.load(path) + assert cache.entries == {} + + +def test_cache_load_drops_malformed_entries(tmp_path: Path) -> None: + """Malformed per-file entries are skipped, valid ones are kept.""" + path = tmp_path / "cache.json" + path.write_text( + json.dumps( + { + "version": split_tests._CACHE_VERSION, + "files": { + "good.py": {"hash": "h1", "fixture_hash": "f1", "count": 3}, + "bad_count.py": { + "hash": "h2", + "fixture_hash": "f2", + "count": "three", + }, + "missing_hash.py": {"fixture_hash": "f3", "count": 4}, + "missing_fixture_hash.py": {"hash": "h4", "count": 4}, + "not_dict.py": 5, + "negative_count.py": { + "hash": "h5", + "fixture_hash": "f5", + "count": -1, + }, + }, + } + ) + ) + cache = split_tests._Cache.load(path) + assert set(cache.entries) == {"good.py"} + + +def test_cache_save_creates_parent_dir(tmp_path: Path) -> None: + """Save mkdirs missing parent dirs so ``--cache foo/bar.json`` works.""" + cache_path = tmp_path / "nested" / "subdir" / "cache.json" + split_tests._Cache(entries={}).save(cache_path) + assert cache_path.is_file() + + +def _resolve( + test_files: list[Path], cache: split_tests._Cache, tree: Path +) -> tuple[dict[Path, split_tests._CacheEntry], list[Path]]: + """Run resolve_entries with a freshly indexed fixtures_by_dir.""" + _, fixtures = split_tests._walk_test_tree(tree) + return split_tests._resolve_entries( + test_files, + cache, + tree, + split_tests._build_fixtures_by_dir(tree, fixtures), + ) + + +def test_resolve_entries_hits_and_misses(tree: Path) -> None: + """Files with matching content + fixture hashes are hits.""" + alpha_one = tree / "components" / "alpha" / "test_one.py" + alpha_two = tree / "components" / "alpha" / "test_two.py" + beta_x = tree / "components" / "beta" / "test_x.py" + + alpha_one_hash = split_tests._hash_file(alpha_one) + alpha_one_fixture = _fixture_hash_for(tree, alpha_one) + cache = split_tests._Cache( + entries={ + str(alpha_one.relative_to(tree)): split_tests._CacheEntry( + hash=alpha_one_hash, fixture_hash=alpha_one_fixture, count=1 + ), + str(alpha_two.relative_to(tree)): split_tests._CacheEntry( + hash="stale", fixture_hash=alpha_one_fixture, count=99 + ), + }, + ) + entries, misses = _resolve([alpha_one, alpha_two, beta_x], cache, tree) + # Hit: cached entry passed through verbatim. + assert entries[alpha_one] == split_tests._CacheEntry( + hash=alpha_one_hash, fixture_hash=alpha_one_fixture, count=1 + ) + # Misses: fresh hashes plus a count=0 placeholder. + assert set(misses) == {alpha_two, beta_x} + assert entries[alpha_two].count == 0 + assert entries[alpha_two].hash == split_tests._hash_file(alpha_two) + assert entries[beta_x].count == 0 + assert entries[beta_x].hash == split_tests._hash_file(beta_x) + + +def test_resolve_entries_misses_on_fixture_drift(tree: Path) -> None: + """A file with unchanged content but changed scope counts as a miss.""" + alpha_one = tree / "components" / "alpha" / "test_one.py" + cache = split_tests._Cache( + entries={ + str(alpha_one.relative_to(tree)): split_tests._CacheEntry( + hash=split_tests._hash_file(alpha_one), + fixture_hash="stale-fixture-hash", + count=1, + ), + }, + ) + _, misses = _resolve([alpha_one], cache, tree) + assert misses == [alpha_one] + + +def test_resolve_entries_isolates_unrelated_dirs(tree: Path) -> None: + """Editing a helper in one dir leaves files in other dirs as hits.""" + alpha_dir = tree / "components" / "alpha" + beta_dir = tree / "components" / "beta" + # Helpers per dir, so a change in alpha doesn't bust beta. + (alpha_dir / "common.py").write_text("# alpha helper v1\n") + (beta_dir / "common.py").write_text("# beta helper v1\n") + alpha_one = alpha_dir / "test_one.py" + beta_x = beta_dir / "test_x.py" + + # Snapshot cache entries with the v1 fixture state. + cache = split_tests._Cache( + entries={ + str(alpha_one.relative_to(tree)): split_tests._CacheEntry( + hash=split_tests._hash_file(alpha_one), + fixture_hash=_fixture_hash_for(tree, alpha_one), + count=1, + ), + str(beta_x.relative_to(tree)): split_tests._CacheEntry( + hash=split_tests._hash_file(beta_x), + fixture_hash=_fixture_hash_for(tree, beta_x), + count=2, + ), + }, + ) + + # Now bust beta's helper; alpha's scope is unchanged, beta's isn't. + (beta_dir / "common.py").write_text("# beta helper v2\n") + _, misses = _resolve([alpha_one, beta_x], cache, tree) + assert misses == [beta_x] + + +def test_collect_tests_hashes_each_file_once(tree: Path) -> None: + """Hits reuse the stored hash, misses reuse the resolve-time hash. + + Guards against regressing the double-read on cache-miss rebuilds: + each test file should pass through _hash_file at most once per run. + """ + cache_path = tree / "cache.json" + alpha_one = tree / "components" / "alpha" / "test_one.py" + # Prime with one hit so we exercise the file-level (not directory-level) miss path. + _prime_cache(cache_path, tree, hits={alpha_one: 1}) + + real_hash = split_tests._hash_file + counts: dict[Path, int] = {} + + def counting_hash(path: Path) -> str: + counts[path] = counts.get(path, 0) + 1 + return real_hash(path) + + # Pin the threshold so the tiny tree stays on the file-level path. + with ( + patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0), + patch.object(split_tests, "_hash_file", side_effect=counting_hash), + patch.object( + split_tests, "_run_collect_batches", side_effect=_echo_one_test_each() + ), + ): + split_tests.collect_tests(tree, cache_path) + + assert all(n == 1 for n in counts.values()), counts + + +def test_collect_tests_warm_cache_skips_pytest(tree: Path) -> None: + """A warm cache with no diffs should skip the pytest subprocess entirely.""" + cache_path = tree / "cache.json" + alpha_one = tree / "components" / "alpha" / "test_one.py" + alpha_two = tree / "components" / "alpha" / "test_two.py" + beta_x = tree / "components" / "beta" / "test_x.py" + _prime_cache(cache_path, tree, hits={alpha_one: 1, alpha_two: 2, beta_x: 3}) + + with patch.object(split_tests, "_run_collect_batches") as run_batches: + folder = split_tests.collect_tests(tree, cache_path) + run_batches.assert_not_called() + assert folder.total_tests == 6 + + +def test_collect_tests_cold_cache_collects_only_missing(tree: Path) -> None: + """A partial cache should only re-collect the files that changed.""" + cache_path = tree / "cache.json" + alpha_one = tree / "components" / "alpha" / "test_one.py" + alpha_two = tree / "components" / "alpha" / "test_two.py" + beta_x = tree / "components" / "beta" / "test_x.py" + + _prime_cache(cache_path, tree, hits={alpha_one: 1}) + + with ( + patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0), + patch.object( + split_tests, "_run_collect_batches", side_effect=_echo_one_test_each() + ) as run_batches, + ): + folder = split_tests.collect_tests(tree, cache_path) + + assert run_batches.call_count == 1 + requested = set(run_batches.call_args.args[0]) + assert requested == {alpha_two, beta_x} + assert folder.total_tests == 3 + + # Cache should now contain entries for every test file. + saved = json.loads(cache_path.read_text()) + assert set(saved["files"]) == { + str(alpha_one.relative_to(tree)), + str(alpha_two.relative_to(tree)), + str(beta_x.relative_to(tree)), + } + + +def test_collect_tests_falls_back_to_dirs_when_misses_dominate(tree: Path) -> None: + """Heavy misses should switch back to dir-level invocation.""" + cache_path = tree / "cache.json" + alpha_one = tree / "components" / "alpha" / "test_one.py" + _prime_cache(cache_path, tree, hits={alpha_one: 1}) + # 2 misses / 3 total = 67% miss, above the 30% default threshold; this + # also covers the new-directory PR case (mostly-new test files). + + with patch.object( + split_tests, "_run_collect_batches", side_effect=_echo_one_test_each() + ) as run_batches: + split_tests.collect_tests(tree, cache_path) + + # We expect the dir-level batch paths, not the individual miss files. + requested = set(run_batches.call_args.args[0]) + assert requested == set(split_tests._enumerate_batch_paths(tree)) + + +def test_collect_tests_caches_files_with_no_collected_tests(tree: Path) -> None: + """Files pytest returns nothing for are cached as 0 so we stop re-collecting them. + + Helper modules named test_*.py with no actual test functions look like + test files to the walker but pytest reports no tests for them. We + want the cache to remember that and skip them on subsequent runs. + """ + cache_path = tree / "cache.json" + alpha_one = tree / "components" / "alpha" / "test_one.py" + alpha_two = tree / "components" / "alpha" / "test_two.py" + beta_x = tree / "components" / "beta" / "test_x.py" + + # Prime the cache with one hit so collect_tests takes the file-level + # diff path; the cold-cache path hands pytest top-level directories + # rather than individual file paths. + _prime_cache(cache_path, tree, hits={alpha_one: 1}) + + with ( + patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0), + patch.object( + split_tests, + "_run_collect_batches", + side_effect=_echo_one_test_each(skip={alpha_two}), + ), + ): + split_tests.collect_tests(tree, cache_path) + + saved = json.loads(cache_path.read_text()) + assert saved["files"][str(alpha_two.relative_to(tree))]["count"] == 0 + assert saved["files"][str(alpha_one.relative_to(tree))]["count"] == 1 + assert saved["files"][str(beta_x.relative_to(tree))]["count"] == 1 + + # Re-running with the same content should now be a full cache hit + # even though alpha_two has no tests. + with patch.object(split_tests, "_run_collect_batches") as run_batches: + folder = split_tests.collect_tests(tree, cache_path) + run_batches.assert_not_called() + # alpha_two contributes 0, only alpha_one + beta_x count. + assert folder.total_tests == 2 + + +def test_collect_tests_drops_deleted_files_from_cache(tree: Path) -> None: + """Files that disappear from disk are dropped from the saved cache.""" + cache_path = tree / "cache.json" + alpha_one = tree / "components" / "alpha" / "test_one.py" + ghost_rel = "components/alpha/test_ghost.py" + + _prime_cache( + cache_path, + tree, + hits={alpha_one: 1}, + extra_entries={ + ghost_rel: split_tests._CacheEntry( + hash="dead", fixture_hash="dead", count=42 + ) + }, + ) + + with ( + patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0), + patch.object( + split_tests, "_run_collect_batches", side_effect=_echo_one_test_each() + ), + ): + split_tests.collect_tests(tree, cache_path) + + saved = json.loads(cache_path.read_text()) + assert ghost_rel not in saved["files"] + + +def _build_folder(tree: Path, counts: dict[Path, int]) -> split_tests.TestFolder: + """Build a TestFolder for ``tree`` populated with ``counts``.""" + folder = split_tests.TestFolder(tree) + for path, n in counts.items(): + folder.add_test_file(split_tests.TestFile(n, path)) + return folder + + +def test_split_tests_keeps_siblings_together_when_snapshots_present( + tmp_path: Path, +) -> None: + """Same-dir files stay together when the folder has syrupy snapshots.""" + one = tmp_path / "alpha" / "test_one.py" + two = tmp_path / "alpha" / "test_two.py" + one.parent.mkdir(parents=True) + one.touch() + two.touch() + # Add a snapshot so the syrupy constraint kicks in. + snapshots = tmp_path / "alpha" / "snapshots" + snapshots.mkdir() + (snapshots / "test_one.ambr").write_text("") + + folder = _build_folder(tmp_path, {one: 60, two: 60}) + holder = split_tests.BucketHolder(tests_per_bucket=50, bucket_count=3) + holder.split_tests(folder) + # Both files must end up in one bucket; the other two stay empty. + sizes = sorted(b.total_tests for b in holder._buckets) + assert sizes == [0, 0, 120] + + +def test_split_tests_splits_siblings_when_no_snapshots(tmp_path: Path) -> None: + """Same-dir files split freely across buckets when no snapshots exist.""" + one = tmp_path / "alpha" / "test_one.py" + two = tmp_path / "alpha" / "test_two.py" + one.parent.mkdir(parents=True) + one.touch() + two.touch() + # No snapshots dir → free to split. + + folder = _build_folder(tmp_path, {one: 60, two: 60}) + holder = split_tests.BucketHolder(tests_per_bucket=70, bucket_count=2) + holder.split_tests(folder) + sizes = sorted(b.total_tests for b in holder._buckets) + assert sizes == [60, 60]