From db6228637267572fa46947be0fb3e7e6c210ee22 Mon Sep 17 00:00:00 2001 From: FIls0010 Date: Sat, 9 May 2026 03:21:05 +0000 Subject: [PATCH 1/4] Fix coordinator data mutation in YouTube diagnostics --- .../components/youtube/diagnostics.py | 10 +- tests/components/youtube/test_diagnostics.py | 95 ++++++++++++++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index 9a898b7e2de7a7..a2903a5307b7e4 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -20,6 +20,12 @@ async def async_get_config_entry_diagnostics( ] sensor_data: dict[str, Any] = {} for channel_id, channel_data in coordinator.data.items(): - channel_data.get(ATTR_LATEST_VIDEO, {}).pop(ATTR_DESCRIPTION) - sensor_data[channel_id] = channel_data + channel_copy = dict(channel_data) + if latest_video := channel_copy.get(ATTR_LATEST_VIDEO): + channel_copy[ATTR_LATEST_VIDEO] = { + key: value + for key, value in latest_video.items() + if key != ATTR_DESCRIPTION + } + sensor_data[channel_id] = channel_copy return sensor_data diff --git a/tests/components/youtube/test_diagnostics.py b/tests/components/youtube/test_diagnostics.py index 99d8b9d5185e75..3d0fa19a178eb9 100644 --- a/tests/components/youtube/test_diagnostics.py +++ b/tests/components/youtube/test_diagnostics.py @@ -2,7 +2,12 @@ from syrupy.assertion import SnapshotAssertion -from homeassistant.components.youtube.const import DOMAIN +from homeassistant.components.youtube.const import ( + ATTR_DESCRIPTION, + ATTR_LATEST_VIDEO, + COORDINATOR, + DOMAIN, +) from homeassistant.core import HomeAssistant from .conftest import ComponentSetup @@ -20,5 +25,91 @@ async def test_diagnostics( """Test diagnostics.""" await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot + + +async def test_diagnostics_does_not_mutate_coordinator_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_integration: ComponentSetup, +) -> None: + """Test that fetching diagnostics does not mutate the coordinator's data. + + Previously, diagnostics called .pop(ATTR_DESCRIPTION) directly on the + coordinator's data dict, permanently removing the description from the + live data after the first diagnostics fetch. + """ + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + + descriptions_before = { + channel_id: channel_data[ATTR_LATEST_VIDEO][ATTR_DESCRIPTION] + for channel_id, channel_data in coordinator.data.items() + if channel_data.get(ATTR_LATEST_VIDEO) is not None + } + + assert descriptions_before, ( + "Test setup should include at least one channel with a latest video" + ) + + await get_diagnostics_for_config_entry(hass, hass_client, entry) + + for channel_id, description in descriptions_before.items(): + assert ( + coordinator.data[channel_id][ATTR_LATEST_VIDEO][ATTR_DESCRIPTION] + == description + ), ( + f"Coordinator data was mutated for channel {channel_id}: " + "description was removed by diagnostics fetch" + ) + + +async def test_diagnostics_description_excluded_from_output( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_integration: ComponentSetup, +) -> None: + """Test that description is excluded from diagnostics output. + + The description is intentionally redacted from diagnostics output, + but this must be done without mutating the coordinator's live data. + """ + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + for channel_id, channel_data in result.items(): + latest_video = channel_data.get(ATTR_LATEST_VIDEO) + if latest_video is not None: + assert ATTR_DESCRIPTION not in latest_video, ( + f"Description should be redacted from diagnostics output " + f"for channel {channel_id}" + ) + + +async def test_diagnostics_no_latest_video( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_integration: ComponentSetup, +) -> None: + """Test diagnostics when a channel has no latest video. + + Previously, the code called .get(ATTR_LATEST_VIDEO, {}).pop(...) which + silently operated on a throwaway empty dict when ATTR_LATEST_VIDEO was None. + This test ensures the None case is handled explicitly and doesn't error. + """ + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + + channel_id = next(iter(coordinator.data)) + original_data = coordinator.data[channel_id].copy() + coordinator.data[channel_id] = {**original_data, ATTR_LATEST_VIDEO: None} + + try: + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert result[channel_id][ATTR_LATEST_VIDEO] is None + finally: + coordinator.data[channel_id] = original_data From 3d1282c38d82aea8ec85ac13f03555e63c74f387 Mon Sep 17 00:00:00 2001 From: FIls0010 Date: Sat, 9 May 2026 13:33:07 +0000 Subject: [PATCH 2/4] Fix coordinator data mutation in YouTube diagnostics --- homeassistant/components/youtube/__init__.py | 25 +++++++------------ .../components/youtube/coordinator.py | 13 ++++++---- .../components/youtube/diagnostics.py | 13 +++------- tests/components/youtube/test_diagnostics.py | 5 ++-- 4 files changed, 23 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index 32863f5a77260d..cb3a9bf03706d0 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -1,10 +1,7 @@ """Support for YouTube.""" -from __future__ import annotations - from aiohttp.client_exceptions import ClientError, ClientResponseError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -16,13 +13,13 @@ ) from .api import AsyncConfigEntryAuth -from .const import AUTH, COORDINATOR, DOMAIN -from .coordinator import YouTubeDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import YouTubeConfigEntry, YouTubeDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YouTubeConfigEntry) -> bool: """Set up YouTube from a config entry.""" try: implementation = await async_get_config_entry_implementation(hass, entry) @@ -49,25 +46,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await delete_devices(hass, entry, coordinator) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - COORDINATOR: coordinator, - AUTH: auth, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YouTubeConfigEntry) -> bool: """Unload a config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def delete_devices( - hass: HomeAssistant, entry: ConfigEntry, coordinator: YouTubeDataUpdateCoordinator + hass: HomeAssistant, + entry: YouTubeConfigEntry, + coordinator: YouTubeDataUpdateCoordinator, ) -> None: """Delete all devices created by integration.""" channel_ids = list(coordinator.data) diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 476e5bb402262f..232eb381f189e7 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the YouTube integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Any @@ -14,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import AsyncConfigEntryAuth +from .api import AsyncConfigEntryAuth from .const import ( ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, @@ -29,14 +27,19 @@ LOGGER, ) +type YouTubeConfigEntry = ConfigEntry[YouTubeDataUpdateCoordinator] + class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """A YouTube Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: YouTubeConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, auth: AsyncConfigEntryAuth + self, + hass: HomeAssistant, + config_entry: YouTubeConfigEntry, + auth: AsyncConfigEntryAuth, ) -> None: """Initialize the YouTube data coordinator.""" self._auth = auth diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index a2903a5307b7e4..f068934105360a 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -1,23 +1,18 @@ """Diagnostics support for YouTube.""" -from __future__ import annotations - from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, COORDINATOR, DOMAIN -from .coordinator import YouTubeDataUpdateCoordinator +from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO +from .coordinator import YouTubeConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: YouTubeConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data sensor_data: dict[str, Any] = {} for channel_id, channel_data in coordinator.data.items(): channel_copy = dict(channel_data) diff --git a/tests/components/youtube/test_diagnostics.py b/tests/components/youtube/test_diagnostics.py index 3d0fa19a178eb9..de62c7be54eb7e 100644 --- a/tests/components/youtube/test_diagnostics.py +++ b/tests/components/youtube/test_diagnostics.py @@ -5,7 +5,6 @@ from homeassistant.components.youtube.const import ( ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, - COORDINATOR, DOMAIN, ) from homeassistant.core import HomeAssistant @@ -41,7 +40,7 @@ async def test_diagnostics_does_not_mutate_coordinator_data( """ await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + coordinator = entry.runtime_data descriptions_before = { channel_id: channel_data[ATTR_LATEST_VIDEO][ATTR_DESCRIPTION] @@ -102,7 +101,7 @@ async def test_diagnostics_no_latest_video( """ await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + coordinator = entry.runtime_data channel_id = next(iter(coordinator.data)) original_data = coordinator.data[channel_id].copy() From a609bfda0d88c6c4e6934daa1860691a788b3953 Mon Sep 17 00:00:00 2001 From: FIls0010 Date: Sun, 10 May 2026 03:49:48 +0000 Subject: [PATCH 3/4] Fix coordinator data mutation in YouTube diagnostics --- homeassistant/components/youtube/diagnostics.py | 13 +++++++++---- tests/components/youtube/test_diagnostics.py | 5 +++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index f068934105360a..a2903a5307b7e4 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -1,18 +1,23 @@ """Diagnostics support for YouTube.""" +from __future__ import annotations + from typing import Any +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO -from .coordinator import YouTubeConfigEntry +from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, COORDINATOR, DOMAIN +from .coordinator import YouTubeDataUpdateCoordinator async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: YouTubeConfigEntry + hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = entry.runtime_data + coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR + ] sensor_data: dict[str, Any] = {} for channel_id, channel_data in coordinator.data.items(): channel_copy = dict(channel_data) diff --git a/tests/components/youtube/test_diagnostics.py b/tests/components/youtube/test_diagnostics.py index de62c7be54eb7e..3d0fa19a178eb9 100644 --- a/tests/components/youtube/test_diagnostics.py +++ b/tests/components/youtube/test_diagnostics.py @@ -5,6 +5,7 @@ from homeassistant.components.youtube.const import ( ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, + COORDINATOR, DOMAIN, ) from homeassistant.core import HomeAssistant @@ -40,7 +41,7 @@ async def test_diagnostics_does_not_mutate_coordinator_data( """ await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - coordinator = entry.runtime_data + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] descriptions_before = { channel_id: channel_data[ATTR_LATEST_VIDEO][ATTR_DESCRIPTION] @@ -101,7 +102,7 @@ async def test_diagnostics_no_latest_video( """ await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - coordinator = entry.runtime_data + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] channel_id = next(iter(coordinator.data)) original_data = coordinator.data[channel_id].copy() From 573331473d394efb0f9b242a365fc41d9810976d Mon Sep 17 00:00:00 2001 From: FIls0010 Date: Mon, 11 May 2026 01:09:49 +0000 Subject: [PATCH 4/4] Fix coordinator data mutation in YouTube diagnostics --- .../components/youtube/diagnostics.py | 13 +-- tests/components/youtube/test_diagnostics.py | 94 +------------------ 2 files changed, 5 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index a2903a5307b7e4..f068934105360a 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -1,23 +1,18 @@ """Diagnostics support for YouTube.""" -from __future__ import annotations - from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, COORDINATOR, DOMAIN -from .coordinator import YouTubeDataUpdateCoordinator +from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO +from .coordinator import YouTubeConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: YouTubeConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data sensor_data: dict[str, Any] = {} for channel_id, channel_data in coordinator.data.items(): channel_copy = dict(channel_data) diff --git a/tests/components/youtube/test_diagnostics.py b/tests/components/youtube/test_diagnostics.py index 3d0fa19a178eb9..c5cb82189a6a9e 100644 --- a/tests/components/youtube/test_diagnostics.py +++ b/tests/components/youtube/test_diagnostics.py @@ -2,12 +2,7 @@ from syrupy.assertion import SnapshotAssertion -from homeassistant.components.youtube.const import ( - ATTR_DESCRIPTION, - ATTR_LATEST_VIDEO, - COORDINATOR, - DOMAIN, -) +from homeassistant.components.youtube.const import DOMAIN from homeassistant.core import HomeAssistant from .conftest import ComponentSetup @@ -26,90 +21,3 @@ async def test_diagnostics( await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot - - -async def test_diagnostics_does_not_mutate_coordinator_data( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - setup_integration: ComponentSetup, -) -> None: - """Test that fetching diagnostics does not mutate the coordinator's data. - - Previously, diagnostics called .pop(ATTR_DESCRIPTION) directly on the - coordinator's data dict, permanently removing the description from the - live data after the first diagnostics fetch. - """ - await setup_integration() - entry = hass.config_entries.async_entries(DOMAIN)[0] - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - - descriptions_before = { - channel_id: channel_data[ATTR_LATEST_VIDEO][ATTR_DESCRIPTION] - for channel_id, channel_data in coordinator.data.items() - if channel_data.get(ATTR_LATEST_VIDEO) is not None - } - - assert descriptions_before, ( - "Test setup should include at least one channel with a latest video" - ) - - await get_diagnostics_for_config_entry(hass, hass_client, entry) - - for channel_id, description in descriptions_before.items(): - assert ( - coordinator.data[channel_id][ATTR_LATEST_VIDEO][ATTR_DESCRIPTION] - == description - ), ( - f"Coordinator data was mutated for channel {channel_id}: " - "description was removed by diagnostics fetch" - ) - - -async def test_diagnostics_description_excluded_from_output( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - setup_integration: ComponentSetup, -) -> None: - """Test that description is excluded from diagnostics output. - - The description is intentionally redacted from diagnostics output, - but this must be done without mutating the coordinator's live data. - """ - await setup_integration() - entry = hass.config_entries.async_entries(DOMAIN)[0] - - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - for channel_id, channel_data in result.items(): - latest_video = channel_data.get(ATTR_LATEST_VIDEO) - if latest_video is not None: - assert ATTR_DESCRIPTION not in latest_video, ( - f"Description should be redacted from diagnostics output " - f"for channel {channel_id}" - ) - - -async def test_diagnostics_no_latest_video( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - setup_integration: ComponentSetup, -) -> None: - """Test diagnostics when a channel has no latest video. - - Previously, the code called .get(ATTR_LATEST_VIDEO, {}).pop(...) which - silently operated on a throwaway empty dict when ATTR_LATEST_VIDEO was None. - This test ensures the None case is handled explicitly and doesn't error. - """ - await setup_integration() - entry = hass.config_entries.async_entries(DOMAIN)[0] - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - - channel_id = next(iter(coordinator.data)) - original_data = coordinator.data[channel_id].copy() - coordinator.data[channel_id] = {**original_data, ATTR_LATEST_VIDEO: None} - - try: - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result[channel_id][ATTR_LATEST_VIDEO] is None - finally: - coordinator.data[channel_id] = original_data