From fa84cfefd38e675447b737282d96c1ae192af34b Mon Sep 17 00:00:00 2001 From: David Rapan Date: Tue, 18 Nov 2025 17:10:22 +0100 Subject: [PATCH 1/8] Reimplement Cloudflare using coordinator Signed-off-by: David Rapan --- .../components/cloudflare/__init__.py | 123 +------------- .../components/cloudflare/coordinator.py | 121 ++++++++++++++ tests/components/cloudflare/conftest.py | 2 +- tests/components/cloudflare/test_init.py | 150 +++++++----------- 4 files changed, 186 insertions(+), 210 deletions(-) create mode 100644 homeassistant/components/cloudflare/coordinator.py diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index 9a05cf48c5936..2f5259ccf8858 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -2,86 +2,19 @@ from __future__ import annotations -import asyncio -from dataclasses import dataclass -from datetime import datetime, timedelta -import logging -import socket - -import pycfdns - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.location import async_detect_location_info -from homeassistant.util.network import is_ipv4_address - -from .const import CONF_RECORDS, DEFAULT_UPDATE_INTERVAL, DOMAIN, SERVICE_UPDATE_RECORDS - -_LOGGER = logging.getLogger(__name__) - -type CloudflareConfigEntry = ConfigEntry[CloudflareRuntimeData] - -@dataclass -class CloudflareRuntimeData: - """Runtime data for Cloudflare config entry.""" - - client: pycfdns.Client - dns_zone: pycfdns.ZoneModel +from .const import DOMAIN, SERVICE_UPDATE_RECORDS +from .coordinator import CloudflareConfigEntry, CloudflareCoordinator async def async_setup_entry(hass: HomeAssistant, entry: CloudflareConfigEntry) -> bool: """Set up Cloudflare from a config entry.""" - session = async_get_clientsession(hass) - client = pycfdns.Client( - api_token=entry.data[CONF_API_TOKEN], - client_session=session, - ) - - try: - dns_zones = await client.list_zones() - dns_zone = next( - zone for zone in dns_zones if zone["name"] == entry.data[CONF_ZONE] - ) - except pycfdns.AuthenticationException as error: - raise ConfigEntryAuthFailed from error - except pycfdns.ComunicationException as error: - raise ConfigEntryNotReady from error - - entry.runtime_data = CloudflareRuntimeData(client, dns_zone) + entry.runtime_data = await CloudflareCoordinator(hass, entry).init() - async def update_records(now: datetime) -> None: - """Set up recurring update.""" - try: - await _async_update_cloudflare(hass, entry) - except ( - pycfdns.AuthenticationException, - pycfdns.ComunicationException, - ) as error: - _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) - - async def update_records_service(call: ServiceCall) -> None: + async def update_records_service(_: ServiceCall) -> None: """Set up service for manual trigger.""" - try: - await _async_update_cloudflare(hass, entry) - except ( - pycfdns.AuthenticationException, - pycfdns.ComunicationException, - ) as error: - _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) - - update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL) - entry.async_on_unload( - async_track_time_interval(hass, update_records, update_interval) - ) + await entry.runtime_data.async_request_refresh() hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service) @@ -92,49 +25,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: CloudflareConfigEntry) """Unload Cloudflare config entry.""" return True - - -async def _async_update_cloudflare( - hass: HomeAssistant, - entry: CloudflareConfigEntry, -) -> None: - client = entry.runtime_data.client - dns_zone = entry.runtime_data.dns_zone - target_records: list[str] = entry.data[CONF_RECORDS] - - _LOGGER.debug("Starting update for zone %s", dns_zone["name"]) - - records = await client.list_dns_records(zone_id=dns_zone["id"], type="A") - _LOGGER.debug("Records: %s", records) - - session = async_get_clientsession(hass, family=socket.AF_INET) - location_info = await async_detect_location_info(session) - - if not location_info or not is_ipv4_address(location_info.ip): - raise HomeAssistantError("Could not get external IPv4 address") - - filtered_records = [ - record - for record in records - if record["name"] in target_records and record["content"] != location_info.ip - ] - - if len(filtered_records) == 0: - _LOGGER.debug("All target records are up to date") - return - - await asyncio.gather( - *[ - client.update_dns_record( - zone_id=dns_zone["id"], - record_id=record["id"], - record_content=location_info.ip, - record_name=record["name"], - record_type=record["type"], - record_proxied=record["proxied"], - ) - for record in filtered_records - ] - ) - - _LOGGER.debug("Update for zone %s is complete", dns_zone["name"]) diff --git a/homeassistant/components/cloudflare/coordinator.py b/homeassistant/components/cloudflare/coordinator.py new file mode 100644 index 0000000000000..2ea72d247337e --- /dev/null +++ b/homeassistant/components/cloudflare/coordinator.py @@ -0,0 +1,121 @@ +"""Contains the Coordinator for updating the IP addresses of your Cloudflare DNS records.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from logging import getLogger +import socket + +import pycfdns + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, CONF_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.location import async_detect_location_info +from homeassistant.util.network import is_ipv4_address + +from .const import CONF_RECORDS, DEFAULT_UPDATE_INTERVAL + +_LOGGER = getLogger(__name__) + +type CloudflareConfigEntry = ConfigEntry[CloudflareCoordinator] + + +class CloudflareCoordinator(DataUpdateCoordinator[None]): + """Coordinates records updates.""" + + config_entry: CloudflareConfigEntry + client: pycfdns.Client + zone: pycfdns.ZoneModel + + def __init__( + self, hass: HomeAssistant, config_entry: CloudflareConfigEntry + ) -> None: + """Initialize an coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL), + ) + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + self.client = pycfdns.Client( + api_token=self.config_entry.data[CONF_API_TOKEN], + client_session=async_get_clientsession(self.hass), + ) + + try: + self.zone = next( + zone + for zone in await self.client.list_zones() + if zone["name"] == self.config_entry.data[CONF_ZONE] + ) + except pycfdns.AuthenticationException as e: + raise ConfigEntryAuthFailed from e + except pycfdns.ComunicationException as e: + raise ConfigEntryNotReady from e + + async def _async_update_data(self) -> None: + """Update records.""" + _LOGGER.debug("Starting update for zone %s", self.zone["name"]) + try: + records = await self.client.list_dns_records( + zone_id=self.zone["id"], type="A" + ) + _LOGGER.debug("Records: %s", records) + + target_records: list[str] = self.config_entry.data[CONF_RECORDS] + + location_info = await async_detect_location_info( + async_get_clientsession(self.hass, family=socket.AF_INET) + ) + + if not location_info or not is_ipv4_address(location_info.ip): + raise UpdateFailed("Could not get external IPv4 address") + + filtered_records = [ + record + for record in records + if record["name"] in target_records + and record["content"] != location_info.ip + ] + + if len(filtered_records) == 0: + _LOGGER.debug("All target records are up to date") + return + + await asyncio.gather( + *[ + self.client.update_dns_record( + zone_id=self.zone["id"], + record_id=record["id"], + record_content=location_info.ip, + record_name=record["name"], + record_type=record["type"], + record_proxied=record["proxied"], + ) + for record in filtered_records + ] + ) + + _LOGGER.debug("Update for zone %s is complete", self.zone["name"]) + + except ( + pycfdns.AuthenticationException, + pycfdns.ComunicationException, + ) as e: + raise UpdateFailed( + f"Error updating zone {self.config_entry.data[CONF_ZONE]}" + ) from e + + async def init(self): + """Asynchronously initialize an coordinator.""" + await super().async_config_entry_first_refresh() + return self diff --git a/tests/components/cloudflare/conftest.py b/tests/components/cloudflare/conftest.py index 977126f39a3e8..e507a4d474304 100644 --- a/tests/components/cloudflare/conftest.py +++ b/tests/components/cloudflare/conftest.py @@ -13,7 +13,7 @@ def cfupdate() -> Generator[MagicMock]: """Mock the CloudflareUpdater for easier testing.""" mock_cfupdate = get_mock_client() with patch( - "homeassistant.components.cloudflare.pycfdns.Client", + "homeassistant.components.cloudflare.coordinator.pycfdns.Client", return_value=mock_cfupdate, ) as mock_api: yield mock_api diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index 15a6c5740ffa1..0298fa61ae9c8 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -14,7 +14,6 @@ ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo @@ -22,19 +21,22 @@ from tests.common import MockConfigEntry, async_fire_time_changed - -async def test_unload_entry(hass: HomeAssistant, cfupdate: MagicMock) -> None: - """Test successful unload of entry.""" - entry = await init_integration(hass) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED - - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) +_LOCATION_PATCH_TARGET = ( + "homeassistant.components.cloudflare.coordinator.async_detect_location_info" +) +_LOCATION_PATCH_VALUE = LocationInfo( + "0.0.0.0", + "US", + "USD", + "CA", + "California", + "San Diego", + "92122", + "America/Los_Angeles", + 32.8594, + -117.2073, + True, +) @pytest.mark.parametrize( @@ -83,31 +85,31 @@ async def test_async_setup_raises_entry_auth_failed( assert flow["context"]["entry_id"] == entry.entry_id +async def test_unload_entry(hass: HomeAssistant, cfupdate: MagicMock) -> None: + """Test successful unload of entry.""" + with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + async def test_integration_services( hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test integration services.""" instance = cfupdate.return_value - entry = await init_integration(hass) - assert entry.state is ConfigEntryState.LOADED - - with patch( - "homeassistant.components.cloudflare.async_detect_location_info", - return_value=LocationInfo( - "0.0.0.0", - "US", - "USD", - "CA", - "California", - "San Diego", - "92122", - "America/Los_Angeles", - 32.8594, - -117.2073, - True, - ), - ): + with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED + await hass.services.async_call( DOMAIN, SERVICE_UPDATE_RECORDS, @@ -116,34 +118,30 @@ async def test_integration_services( ) await hass.async_block_till_done() - assert len(instance.update_dns_record.mock_calls) == 2 + assert len(instance.update_dns_record.mock_calls) == 4 assert "All target records are up to date" not in caplog.text async def test_integration_services_with_issue( - hass: HomeAssistant, cfupdate: MagicMock + hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test integration services with issue.""" instance = cfupdate.return_value - entry = await init_integration(hass) - assert entry.state is ConfigEntryState.LOADED + with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED - with ( - patch( - "homeassistant.components.cloudflare.async_detect_location_info", - return_value=None, - ), - pytest.raises(HomeAssistantError, match="Could not get external IPv4 address"), - ): - await hass.services.async_call( - DOMAIN, - SERVICE_UPDATE_RECORDS, - {}, - blocking=True, - ) + with patch(_LOCATION_PATCH_TARGET, return_value=None): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_RECORDS, + {}, + blocking=True, + ) - instance.update_dns_record.assert_not_called() + assert len(instance.update_dns_record.mock_calls) == 2 + assert "Could not get external IPv4 address" in caplog.text async def test_integration_services_with_nonexisting_record( @@ -152,27 +150,12 @@ async def test_integration_services_with_nonexisting_record( """Test integration services.""" instance = cfupdate.return_value - entry = await init_integration( - hass, data={**ENTRY_CONFIG, CONF_RECORDS: ["nonexisting.example.com"]} - ) - assert entry.state is ConfigEntryState.LOADED - - with patch( - "homeassistant.components.cloudflare.async_detect_location_info", - return_value=LocationInfo( - "0.0.0.0", - "US", - "USD", - "CA", - "California", - "San Diego", - "92122", - "America/Los_Angeles", - 32.8594, - -117.2073, - True, - ), - ): + with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): + entry = await init_integration( + hass, data={**ENTRY_CONFIG, CONF_RECORDS: ["nonexisting.example.com"]} + ) + assert entry.state is ConfigEntryState.LOADED + await hass.services.async_call( DOMAIN, SERVICE_UPDATE_RECORDS, @@ -193,25 +176,10 @@ async def test_integration_update_interval( """Test integration update interval.""" instance = cfupdate.return_value - entry = await init_integration(hass) - assert entry.state is ConfigEntryState.LOADED - - with patch( - "homeassistant.components.cloudflare.async_detect_location_info", - return_value=LocationInfo( - "0.0.0.0", - "US", - "USD", - "CA", - "California", - "San Diego", - "92122", - "America/Los_Angeles", - 32.8594, - -117.2073, - True, - ), - ): + with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED + async_fire_time_changed( hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) ) From 0764563289d80ba93cdf4c9ca729f5a835d87336 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Tue, 18 Nov 2025 17:43:23 +0100 Subject: [PATCH 2/8] Use location_info fixture Signed-off-by: David Rapan --- tests/components/cloudflare/conftest.py | 28 +++++ tests/components/cloudflare/test_init.py | 149 ++++++++++------------- 2 files changed, 93 insertions(+), 84 deletions(-) diff --git a/tests/components/cloudflare/conftest.py b/tests/components/cloudflare/conftest.py index e507a4d474304..841773e08b985 100644 --- a/tests/components/cloudflare/conftest.py +++ b/tests/components/cloudflare/conftest.py @@ -5,8 +5,14 @@ import pytest +from homeassistant.util.location import LocationInfo + from . import get_mock_client +LOCATION_PATCH_TARGET = ( + "homeassistant.components.cloudflare.coordinator.async_detect_location_info" +) + @pytest.fixture def cfupdate() -> Generator[MagicMock]: @@ -28,3 +34,25 @@ def cfupdate_flow() -> Generator[MagicMock]: return_value=mock_cfupdate, ) as mock_api: yield mock_api + + +@pytest.fixture +def location_info() -> Generator: + """Mock the CloudflareUpdater for easier testing.""" + with patch( + LOCATION_PATCH_TARGET, + return_value=LocationInfo( + "0.0.0.0", + "US", + "USD", + "CA", + "California", + "San Diego", + "92122", + "America/Los_Angeles", + 32.8594, + -117.2073, + True, + ), + ): + yield \ No newline at end of file diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index 0298fa61ae9c8..92e6c7db4eb51 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -1,7 +1,7 @@ """Test the Cloudflare integration.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pycfdns import pytest @@ -15,29 +15,12 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from homeassistant.util.location import LocationInfo from . import ENTRY_CONFIG, init_integration +from .conftest import LOCATION_PATCH_TARGET from tests.common import MockConfigEntry, async_fire_time_changed -_LOCATION_PATCH_TARGET = ( - "homeassistant.components.cloudflare.coordinator.async_detect_location_info" -) -_LOCATION_PATCH_VALUE = LocationInfo( - "0.0.0.0", - "US", - "USD", - "CA", - "California", - "San Diego", - "92122", - "America/Los_Angeles", - 32.8594, - -117.2073, - True, -) - @pytest.mark.parametrize( "side_effect", @@ -85,118 +68,116 @@ async def test_async_setup_raises_entry_auth_failed( assert flow["context"]["entry_id"] == entry.entry_id +@pytest.mark.usefixtures("location_info") async def test_unload_entry(hass: HomeAssistant, cfupdate: MagicMock) -> None: """Test successful unload of entry.""" - with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): - entry = await init_integration(hass) + entry = await init_integration(hass) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) +@pytest.mark.usefixtures("location_info") async def test_integration_services( hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test integration services.""" instance = cfupdate.return_value - with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): - entry = await init_integration(hass) - assert entry.state is ConfigEntryState.LOADED + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED - await hass.services.async_call( - DOMAIN, - SERVICE_UPDATE_RECORDS, - {}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_RECORDS, + {}, + blocking=True, + ) + await hass.async_block_till_done() assert len(instance.update_dns_record.mock_calls) == 4 assert "All target records are up to date" not in caplog.text +@pytest.mark.usefixtures("location_info") async def test_integration_services_with_issue( hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test integration services with issue.""" instance = cfupdate.return_value - with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): - entry = await init_integration(hass) - assert entry.state is ConfigEntryState.LOADED + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED - with patch(_LOCATION_PATCH_TARGET, return_value=None): - await hass.services.async_call( - DOMAIN, - SERVICE_UPDATE_RECORDS, - {}, - blocking=True, - ) + with patch(LOCATION_PATCH_TARGET, return_value=None): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_RECORDS, + {}, + blocking=True, + ) assert len(instance.update_dns_record.mock_calls) == 2 assert "Could not get external IPv4 address" in caplog.text +@pytest.mark.usefixtures("location_info") async def test_integration_services_with_nonexisting_record( hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test integration services.""" instance = cfupdate.return_value - with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): - entry = await init_integration( - hass, data={**ENTRY_CONFIG, CONF_RECORDS: ["nonexisting.example.com"]} - ) - assert entry.state is ConfigEntryState.LOADED - - await hass.services.async_call( - DOMAIN, - SERVICE_UPDATE_RECORDS, - {}, - blocking=True, - ) - await hass.async_block_till_done() + entry = await init_integration( + hass, data={**ENTRY_CONFIG, CONF_RECORDS: ["nonexisting.example.com"]} + ) + assert entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_RECORDS, + {}, + blocking=True, + ) + await hass.async_block_till_done() instance.update_dns_record.assert_not_called() assert "All target records are up to date" in caplog.text +@pytest.mark.usefixtures("location_info") async def test_integration_update_interval( - hass: HomeAssistant, - cfupdate: MagicMock, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, cfupdate: MagicMock, caplog: pytest.LogCaptureFixture ) -> None: """Test integration update interval.""" instance = cfupdate.return_value - with patch(_LOCATION_PATCH_TARGET, return_value=_LOCATION_PATCH_VALUE): - entry = await init_integration(hass) - assert entry.state is ConfigEntryState.LOADED + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(instance.update_dns_record.mock_calls) == 2 - assert "All target records are up to date" not in caplog.text + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(instance.update_dns_record.mock_calls) == 2 + assert "All target records are up to date" not in caplog.text - instance.list_dns_records.side_effect = pycfdns.AuthenticationException() - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(instance.update_dns_record.mock_calls) == 2 + instance.list_dns_records.side_effect = pycfdns.AuthenticationException() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(instance.update_dns_record.mock_calls) == 2 - instance.list_dns_records.side_effect = pycfdns.ComunicationException() - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(instance.update_dns_record.mock_calls) == 2 + instance.list_dns_records.side_effect = pycfdns.ComunicationException() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(instance.update_dns_record.mock_calls) == 2 From ef94763421a4de5a191660605b1a81baca2f288a Mon Sep 17 00:00:00 2001 From: David Rapan Date: Tue, 18 Nov 2025 17:44:17 +0100 Subject: [PATCH 3/8] Remove Mock import Signed-off-by: David Rapan --- tests/components/cloudflare/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index 92e6c7db4eb51..a0bd3c474eadb 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -1,7 +1,7 @@ """Test the Cloudflare integration.""" from datetime import timedelta -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch import pycfdns import pytest From 3f7e0f8eccd801a8f3119ecd9f578b970cfb33a1 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Tue, 18 Nov 2025 17:46:50 +0100 Subject: [PATCH 4/8] Fix ruffik Signed-off-by: David Rapan --- tests/components/cloudflare/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/cloudflare/conftest.py b/tests/components/cloudflare/conftest.py index 841773e08b985..6caf44800693d 100644 --- a/tests/components/cloudflare/conftest.py +++ b/tests/components/cloudflare/conftest.py @@ -55,4 +55,4 @@ def location_info() -> Generator: True, ), ): - yield \ No newline at end of file + yield From 4ded7aea49ecbea63f51b460caead401bd997aa9 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Tue, 18 Nov 2025 17:51:50 +0100 Subject: [PATCH 5/8] Add None Signed-off-by: David Rapan --- tests/components/cloudflare/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/cloudflare/conftest.py b/tests/components/cloudflare/conftest.py index 6caf44800693d..44d54db328304 100644 --- a/tests/components/cloudflare/conftest.py +++ b/tests/components/cloudflare/conftest.py @@ -37,7 +37,7 @@ def cfupdate_flow() -> Generator[MagicMock]: @pytest.fixture -def location_info() -> Generator: +def location_info() -> Generator[None]: """Mock the CloudflareUpdater for easier testing.""" with patch( LOCATION_PATCH_TARGET, From 85d27518ed7e054a943c7dc9b6bf5f50cce7ae5f Mon Sep 17 00:00:00 2001 From: David Rapan Date: Wed, 19 Nov 2025 02:04:18 +0100 Subject: [PATCH 6/8] Add missing dummy listener! Signed-off-by: David Rapan --- homeassistant/components/cloudflare/coordinator.py | 12 ++++++++++-- tests/components/cloudflare/test_init.py | 9 ++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloudflare/coordinator.py b/homeassistant/components/cloudflare/coordinator.py index 2ea72d247337e..24a2a2640387a 100644 --- a/homeassistant/components/cloudflare/coordinator.py +++ b/homeassistant/components/cloudflare/coordinator.py @@ -6,12 +6,13 @@ from datetime import timedelta from logging import getLogger import socket +from typing import Self import pycfdns from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, CONF_ZONE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -115,7 +116,14 @@ async def _async_update_data(self) -> None: f"Error updating zone {self.config_entry.data[CONF_ZONE]}" ) from e - async def init(self): + async def init(self) -> Self: """Asynchronously initialize an coordinator.""" await super().async_config_entry_first_refresh() + + @callback + def _callback() -> None: + """Records updated callback.""" + + self.config_entry.async_on_unload(self.async_add_listener(_callback, None)) + return self diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index a0bd3c474eadb..5066924b4fc3f 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -165,7 +165,8 @@ async def test_integration_update_interval( hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) ) await hass.async_block_till_done(wait_background_tasks=True) - assert len(instance.update_dns_record.mock_calls) == 2 + assert len(instance.list_dns_records.mock_calls) == 2 + assert len(instance.update_dns_record.mock_calls) == 4 assert "All target records are up to date" not in caplog.text instance.list_dns_records.side_effect = pycfdns.AuthenticationException() @@ -173,11 +174,13 @@ async def test_integration_update_interval( hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) ) await hass.async_block_till_done(wait_background_tasks=True) - assert len(instance.update_dns_record.mock_calls) == 2 + assert len(instance.list_dns_records.mock_calls) == 3 + assert len(instance.update_dns_record.mock_calls) == 4 instance.list_dns_records.side_effect = pycfdns.ComunicationException() async_fire_time_changed( hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) ) await hass.async_block_till_done(wait_background_tasks=True) - assert len(instance.update_dns_record.mock_calls) == 2 + assert len(instance.list_dns_records.mock_calls) == 4 + assert len(instance.update_dns_record.mock_calls) == 4 From 25e240c27a5656914616d87451fddcd203824a55 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Wed, 19 Nov 2025 11:49:15 +0100 Subject: [PATCH 7/8] Fix error map during setup Signed-off-by: David Rapan --- homeassistant/components/cloudflare/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloudflare/coordinator.py b/homeassistant/components/cloudflare/coordinator.py index 24a2a2640387a..465d7354c919f 100644 --- a/homeassistant/components/cloudflare/coordinator.py +++ b/homeassistant/components/cloudflare/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.location import async_detect_location_info @@ -61,7 +61,7 @@ async def _async_setup(self) -> None: except pycfdns.AuthenticationException as e: raise ConfigEntryAuthFailed from e except pycfdns.ComunicationException as e: - raise ConfigEntryNotReady from e + raise ConfigEntryError from e async def _async_update_data(self) -> None: """Update records.""" From 07cc9959bcb5ad8ae3544caca72c1599cc9508c6 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Wed, 19 Nov 2025 12:04:43 +0100 Subject: [PATCH 8/8] Align to UpdateFailed error Signed-off-by: David Rapan --- homeassistant/components/cloudflare/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloudflare/coordinator.py b/homeassistant/components/cloudflare/coordinator.py index 465d7354c919f..e1e1823c22b7d 100644 --- a/homeassistant/components/cloudflare/coordinator.py +++ b/homeassistant/components/cloudflare/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.location import async_detect_location_info @@ -61,7 +61,7 @@ async def _async_setup(self) -> None: except pycfdns.AuthenticationException as e: raise ConfigEntryAuthFailed from e except pycfdns.ComunicationException as e: - raise ConfigEntryError from e + raise UpdateFailed("Error communicating with API") from e async def _async_update_data(self) -> None: """Update records."""