From eee46de28d7ca79befcd648cbd79708072668292 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Tue, 5 May 2026 00:00:41 +0930 Subject: [PATCH 01/11] Use runtime_data and validate connection at setup for dnsip --- homeassistant/components/dnsip/__init__.py | 68 +++++++++++++++++-- homeassistant/components/dnsip/sensor.py | 50 +++++++------- tests/components/dnsip/test_init.py | 76 ++++++++++++++++++++-- tests/components/dnsip/test_sensor.py | 48 +++++--------- 4 files changed, 177 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index ec5a9f033d248..5d15f4730f87e 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -1,23 +1,83 @@ """The DNS IP integration.""" +import asyncio +from dataclasses import dataclass + +import aiodns +from aiodns.error import DNSError + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import _LOGGER, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + CONF_HOSTNAME, + CONF_IPV4, + CONF_IPV6, + CONF_PORT_IPV6, + CONF_RESOLVER, + CONF_RESOLVER_IPV6, + DEFAULT_PORT, + PLATFORMS, +) + -from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS +@dataclass +class DnsIPRuntimeData: + """Runtime data for DNS IP integration.""" + resolver_ipv4: aiodns.DNSResolver + resolver_ipv6: aiodns.DNSResolver -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool: """Set up DNS IP from a config entry.""" + nameserver_ipv4 = entry.options[CONF_RESOLVER] + nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6] + port_ipv4 = entry.options[CONF_PORT] + port_ipv6 = entry.options[CONF_PORT_IPV6] + + resolver_ipv4 = aiodns.DNSResolver( + nameservers=[nameserver_ipv4], tcp_port=port_ipv4, udp_port=port_ipv4 + ) + resolver_ipv6 = aiodns.DNSResolver( + nameservers=[nameserver_ipv6], tcp_port=port_ipv6, udp_port=port_ipv6 + ) + + hostname = entry.data[CONF_HOSTNAME] + try: + async with asyncio.timeout(10): + if entry.data[CONF_IPV4]: + await resolver_ipv4.query(hostname, "A") + elif entry.data[CONF_IPV6]: + await resolver_ipv6.query(hostname, "AAAA") + except (TimeoutError, DNSError) as err: + await resolver_ipv4.close() + await resolver_ipv6.close() + raise ConfigEntryNotReady(f"DNS lookup failed for {hostname}: {err}") from err + + entry.runtime_data = DnsIPRuntimeData( + resolver_ipv4=resolver_ipv4, + resolver_ipv6=resolver_ipv6, + ) + 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: DnsIPConfigEntry) -> bool: """Unload DNS IP config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await entry.runtime_data.resolver_ipv4.close() + await entry.runtime_data.resolver_ipv6.close() + return unload_ok async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index dd5a4f38aab37..b3816b9ba4a60 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -10,17 +10,16 @@ from aiodns.error import DNSError from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import DnsIPConfigEntry from .const import ( CONF_HOSTNAME, CONF_IPV4, CONF_IPV6, - CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, DOMAIN, @@ -46,7 +45,7 @@ def sort_ips(ips: list, querytype: Literal["A", "AAAA"]) -> list: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: DnsIPConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the dnsip sensor entry.""" @@ -54,16 +53,27 @@ async def async_setup_entry( hostname = entry.data[CONF_HOSTNAME] name = entry.data[CONF_NAME] - nameserver_ipv4 = entry.options[CONF_RESOLVER] - nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6] - port_ipv4 = entry.options[CONF_PORT] - port_ipv6 = entry.options[CONF_PORT_IPV6] - entities = [] if entry.data[CONF_IPV4]: - entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4)) + entities.append( + WanIpSensor( + name, + hostname, + entry.options[CONF_RESOLVER], + False, + entry.runtime_data.resolver_ipv4, + ) + ) if entry.data[CONF_IPV6]: - entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6)) + entities.append( + WanIpSensor( + name, + hostname, + entry.options[CONF_RESOLVER_IPV6], + True, + entry.runtime_data.resolver_ipv6, + ) + ) async_add_entities(entities, update_before_add=True) @@ -75,22 +85,19 @@ class WanIpSensor(SensorEntity): _attr_translation_key = "dnsip" _unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"}) - resolver: aiodns.DNSResolver - def __init__( self, name: str, hostname: str, nameserver: str, ipv6: bool, - port: int, + resolver: aiodns.DNSResolver, ) -> None: """Initialize the DNS IP sensor.""" self._attr_name = "IPv6" if ipv6 else None self._attr_unique_id = f"{hostname}_{ipv6}" self.hostname = hostname - self.port = port - self.nameserver = nameserver + self.resolver = resolver self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { @@ -104,28 +111,17 @@ def __init__( model=aiodns.__version__, name=name, ) - self.create_dns_resolver() - - def create_dns_resolver(self) -> None: - """Create the DNS resolver.""" - self.resolver = aiodns.DNSResolver( - nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port - ) async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" - if self.resolver._closed: # noqa: SLF001 - self.create_dns_resolver() response = None try: async with asyncio.timeout(10): response = await self.resolver.query(self.hostname, self.querytype) except TimeoutError as err: _LOGGER.debug("Timeout while resolving host: %s", err) - await self.resolver.close() except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) - await self.resolver.close() if response: sorted_ips = sort_ips( diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 1181c391ca2f5..bce9da3521dad 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import patch +from aiodns.error import DNSError + from homeassistant.components.dnsip.const import ( CONF_HOSTNAME, CONF_IPV4, @@ -44,7 +46,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -82,7 +84,7 @@ async def test_port_migration( entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -123,7 +125,7 @@ async def test_remove_unique_id_migration( entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -160,7 +162,7 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -168,3 +170,69 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: entry = hass.config_entries.async_get_entry(entry.entry_id) assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_setup_dns_error(hass: HomeAssistant) -> None: + """Test setup raises ConfigEntryNotReady when DNS lookup fails.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: False, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + entry_id="1", + unique_id="home-assistant.io", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.aiodns.DNSResolver", + return_value=RetrieveDNS(error=DNSError()), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_ipv6_only(hass: HomeAssistant) -> None: + """Test setup with only IPv6 enabled exercises the IPv6 lookup branch.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: False, + CONF_IPV6: True, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + entry_id="1", + unique_id="home-assistant.io", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index b7dae63476511..da5e563201f46 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -48,7 +48,7 @@ async def test_sensor(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -91,7 +91,7 @@ async def test_legacy_sensor(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) @@ -137,7 +137,7 @@ async def test_sensor_no_response( dns_mock = RetrieveDNS() with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=dns_mock, ): await hass.config_entries.async_setup(entry.entry_id) @@ -148,24 +148,19 @@ async def test_sensor_no_response( assert state.state == "1.1.1.1" dns_mock.error = DNSError() - with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", - return_value=dns_mock, - ): - freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) - async_fire_time_changed(hass) - freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - # Allows 2 retries before going unavailable - state = hass.states.get("sensor.home_assistant_io") - assert state.state == "1.1.1.1" - assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] + state = hass.states.get("sensor.home_assistant_io") + assert state.state == "1.1.1.1" + assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] - freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() state = hass.states.get("sensor.home_assistant_io") assert state.state == STATE_UNAVAILABLE @@ -197,7 +192,7 @@ async def test_sensor_timeout( dns_mock = RetrieveDNS() with patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + "homeassistant.components.dnsip.aiodns.DNSResolver", return_value=dns_mock, ): await hass.config_entries.async_setup(entry.entry_id) @@ -207,21 +202,14 @@ async def test_sensor_timeout( assert state.state == "1.1.1.1" - with ( - patch( - "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", - return_value=dns_mock, - ), - patch( - "homeassistant.components.dnsip.sensor.asyncio.timeout", - side_effect=TimeoutError(), - ), + with patch( + "homeassistant.components.dnsip.sensor.asyncio.timeout", + side_effect=TimeoutError(), ): freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) async_fire_time_changed(hass) await hass.async_block_till_done() - # Allows 2 retries before going unavailable state = hass.states.get("sensor.home_assistant_io") assert state.state == "1.1.1.1" assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] From f4e6a78a3c5611001bbc8ebb756b3b77a8a254a8 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Tue, 5 May 2026 13:03:24 +0930 Subject: [PATCH 02/11] Address review feedback for dnsip runtime_data PR --- homeassistant/components/dnsip/__init__.py | 39 ++++++++--- homeassistant/components/dnsip/sensor.py | 18 ++++- tests/components/dnsip/test_init.py | 79 ++++++++++++++++++++++ tests/components/dnsip/test_sensor.py | 38 +++++++---- 4 files changed, 151 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 5d15f4730f87e..05b77dc272e3b 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -50,23 +50,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo ) hostname = entry.data[CONF_HOSTNAME] + queries: list = [] + if entry.data[CONF_IPV4]: + queries.append(resolver_ipv4.query(hostname, "A")) + if entry.data[CONF_IPV6]: + queries.append(resolver_ipv6.query(hostname, "AAAA")) + try: async with asyncio.timeout(10): - if entry.data[CONF_IPV4]: - await resolver_ipv4.query(hostname, "A") - elif entry.data[CONF_IPV6]: - await resolver_ipv6.query(hostname, "AAAA") - except (TimeoutError, DNSError) as err: + results = await asyncio.gather(*queries, return_exceptions=True) + except TimeoutError as err: + await resolver_ipv4.close() + await resolver_ipv6.close() + raise ConfigEntryNotReady( + f"DNS lookup timed out for {hostname}: {err}" + ) from err + + errors = [ + result for result in results if isinstance(result, (TimeoutError, DNSError)) + ] + if errors and len(errors) == len(results): await resolver_ipv4.close() await resolver_ipv6.close() - raise ConfigEntryNotReady(f"DNS lookup failed for {hostname}: {err}") from err + raise ConfigEntryNotReady( + f"DNS lookup failed for {hostname}: {errors[0]}" + ) from errors[0] entry.runtime_data = DnsIPRuntimeData( resolver_ipv4=resolver_ipv4, resolver_ipv6=resolver_ipv6, ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + try: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + except Exception: + await resolver_ipv4.close() + await resolver_ipv6.close() + raise + return True @@ -80,7 +101,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bo return unload_ok -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: DnsIPConfigEntry +) -> bool: """Migrate old entry to a newer version.""" if config_entry.version > 1: diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index b3816b9ba4a60..bca057f351d3e 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -10,7 +10,7 @@ from aiodns.error import DNSError from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -20,6 +20,7 @@ CONF_HOSTNAME, CONF_IPV4, CONF_IPV6, + CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, DOMAIN, @@ -61,6 +62,7 @@ async def async_setup_entry( hostname, entry.options[CONF_RESOLVER], False, + entry.options[CONF_PORT], entry.runtime_data.resolver_ipv4, ) ) @@ -71,6 +73,7 @@ async def async_setup_entry( hostname, entry.options[CONF_RESOLVER_IPV6], True, + entry.options[CONF_PORT_IPV6], entry.runtime_data.resolver_ipv6, ) ) @@ -91,12 +94,15 @@ def __init__( hostname: str, nameserver: str, ipv6: bool, + port: int, resolver: aiodns.DNSResolver, ) -> None: """Initialize the DNS IP sensor.""" self._attr_name = "IPv6" if ipv6 else None self._attr_unique_id = f"{hostname}_{ipv6}" self.hostname = hostname + self.port = port + self.nameserver = nameserver self.resolver = resolver self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES @@ -112,16 +118,26 @@ def __init__( name=name, ) + def create_dns_resolver(self) -> None: + """Create the DNS resolver.""" + self.resolver = aiodns.DNSResolver( + nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port + ) + async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" + if self.resolver._closed: # noqa: SLF001 + self.create_dns_resolver() response = None try: async with asyncio.timeout(10): response = await self.resolver.query(self.hostname, self.querytype) except TimeoutError as err: _LOGGER.debug("Timeout while resolving host: %s", err) + await self.resolver.close() except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) + await self.resolver.close() if response: sorted_ips = sort_ips( diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index bce9da3521dad..6a964e26f4d57 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -236,3 +236,82 @@ async def test_setup_ipv6_only(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED + + +async def test_setup_dns_timeout(hass: HomeAssistant) -> None: + """Test setup raises ConfigEntryNotReady when DNS lookup times out.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: False, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + entry_id="1", + unique_id="home-assistant.io", + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.dnsip.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ), + patch( + "homeassistant.components.dnsip.asyncio.timeout", + side_effect=TimeoutError(), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_forward_failure(hass: HomeAssistant) -> None: + """Test resolvers are closed when platform forwarding raises.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: False, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + entry_id="1", + unique_id="home-assistant.io", + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.dnsip.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ), + patch.object( + hass.config_entries, + "async_forward_entry_setups", + side_effect=Exception("forward failed"), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index da5e563201f46..39036ab621ac8 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -148,19 +148,23 @@ async def test_sensor_no_response( assert state.state == "1.1.1.1" dns_mock.error = DNSError() - freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) - async_fire_time_changed(hass) - freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + with patch( + "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + return_value=dns_mock, + ): + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state = hass.states.get("sensor.home_assistant_io") - assert state.state == "1.1.1.1" - assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] + state = hass.states.get("sensor.home_assistant_io") + assert state.state == "1.1.1.1" + assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] - freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() state = hass.states.get("sensor.home_assistant_io") assert state.state == STATE_UNAVAILABLE @@ -202,9 +206,15 @@ async def test_sensor_timeout( assert state.state == "1.1.1.1" - with patch( - "homeassistant.components.dnsip.sensor.asyncio.timeout", - side_effect=TimeoutError(), + with ( + patch( + "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + return_value=dns_mock, + ), + patch( + "homeassistant.components.dnsip.sensor.asyncio.timeout", + side_effect=TimeoutError(), + ), ): freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) async_fire_time_changed(hass) From 57cf2a8265b7dec6107d53c140c009e0626f4876 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Tue, 5 May 2026 23:28:00 +0930 Subject: [PATCH 03/11] Use side_effect for distinct resolver mocks per Copilot feedback --- tests/components/dnsip/test_init.py | 12 ++++++------ tests/components/dnsip/test_sensor.py | 20 +++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 6a964e26f4d57..09e55b23e79a1 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -47,7 +47,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(), + side_effect=[RetrieveDNS(), RetrieveDNS()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -85,7 +85,7 @@ async def test_port_migration( with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(), + side_effect=[RetrieveDNS(), RetrieveDNS()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -126,7 +126,7 @@ async def test_remove_unique_id_migration( with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(), + side_effect=[RetrieveDNS(), RetrieveDNS()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -163,7 +163,7 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(), + side_effect=[RetrieveDNS(), RetrieveDNS()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -197,7 +197,7 @@ async def test_setup_dns_error(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(error=DNSError()), + side_effect=[RetrieveDNS(error=DNSError()), RetrieveDNS(error=DNSError())], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -230,7 +230,7 @@ async def test_setup_ipv6_only(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(), + side_effect=[RetrieveDNS(), RetrieveDNS()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index 39036ab621ac8..58d9f9f8f301e 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -49,7 +49,7 @@ async def test_sensor(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(), + side_effect=[RetrieveDNS(), RetrieveDNS()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -92,7 +92,7 @@ async def test_legacy_sensor(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(), + side_effect=[RetrieveDNS(), RetrieveDNS()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -135,10 +135,11 @@ async def test_sensor_no_response( ) entry.add_to_hass(hass) - dns_mock = RetrieveDNS() + dns_mock_ipv4 = RetrieveDNS() + dns_mock_ipv6 = RetrieveDNS() with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=dns_mock, + side_effect=[dns_mock_ipv4, dns_mock_ipv6], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -147,10 +148,10 @@ async def test_sensor_no_response( assert state.state == "1.1.1.1" - dns_mock.error = DNSError() + dns_mock_ipv4.error = DNSError() with patch( "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", - return_value=dns_mock, + return_value=dns_mock_ipv4, ): freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) async_fire_time_changed(hass) @@ -194,10 +195,11 @@ async def test_sensor_timeout( ) entry.add_to_hass(hass) - dns_mock = RetrieveDNS() + dns_mock_ipv4 = RetrieveDNS() + dns_mock_ipv6 = RetrieveDNS() with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=dns_mock, + side_effect=[dns_mock_ipv4, dns_mock_ipv6], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -209,7 +211,7 @@ async def test_sensor_timeout( with ( patch( "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", - return_value=dns_mock, + return_value=dns_mock_ipv4, ), patch( "homeassistant.components.dnsip.sensor.asyncio.timeout", From 4724cc745acc1525a8a3e23ec276ae14c2bbe299 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Wed, 6 May 2026 10:16:03 +0930 Subject: [PATCH 04/11] Store recreated resolver back into runtime_data per Copilot feedback --- homeassistant/components/dnsip/sensor.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index bca057f351d3e..1007f4b1a8578 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -58,23 +58,23 @@ async def async_setup_entry( if entry.data[CONF_IPV4]: entities.append( WanIpSensor( + entry, name, hostname, entry.options[CONF_RESOLVER], False, entry.options[CONF_PORT], - entry.runtime_data.resolver_ipv4, ) ) if entry.data[CONF_IPV6]: entities.append( WanIpSensor( + entry, name, hostname, entry.options[CONF_RESOLVER_IPV6], True, entry.options[CONF_PORT_IPV6], - entry.runtime_data.resolver_ipv6, ) ) @@ -90,20 +90,26 @@ class WanIpSensor(SensorEntity): def __init__( self, + entry: DnsIPConfigEntry, name: str, hostname: str, nameserver: str, ipv6: bool, port: int, - resolver: aiodns.DNSResolver, ) -> None: """Initialize the DNS IP sensor.""" + self.entry = entry + self.ipv6 = ipv6 self._attr_name = "IPv6" if ipv6 else None self._attr_unique_id = f"{hostname}_{ipv6}" self.hostname = hostname self.port = port self.nameserver = nameserver - self.resolver = resolver + self.resolver = ( + entry.runtime_data.resolver_ipv6 + if ipv6 + else entry.runtime_data.resolver_ipv4 + ) self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { @@ -124,6 +130,11 @@ def create_dns_resolver(self) -> None: nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port ) + if self.ipv6: + self.entry.runtime_data.resolver_ipv6 = self.resolver + else: + self.entry.runtime_data.resolver_ipv4 = self.resolver + async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" if self.resolver._closed: # noqa: SLF001 From 52a8e49010ee96a5dd7eb34fbbd14a823f378078 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Wed, 6 May 2026 11:18:17 +0930 Subject: [PATCH 05/11] Verify resolver cleanup in test_setup_forward_failure --- tests/components/dnsip/test_init.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 09e55b23e79a1..047125243a257 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -300,10 +300,12 @@ async def test_setup_forward_failure(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) + dns_mock_ipv4 = RetrieveDNS() + dns_mock_ipv6 = RetrieveDNS() with ( patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - return_value=RetrieveDNS(), + side_effect=[dns_mock_ipv4, dns_mock_ipv6], ), patch.object( hass.config_entries, @@ -315,3 +317,5 @@ async def test_setup_forward_failure(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_ERROR + assert dns_mock_ipv4._closed + assert dns_mock_ipv6._closed From 388bd6883859dbfe55f3b6e0bab39037fec80cc8 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Wed, 6 May 2026 11:37:52 +0930 Subject: [PATCH 06/11] Use module-local logger in dnsip __init__ --- homeassistant/components/dnsip/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 05b77dc272e3b..79ea9193a5522 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -2,13 +2,14 @@ import asyncio from dataclasses import dataclass +import logging import aiodns from aiodns.error import DNSError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT -from homeassistant.core import _LOGGER, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( @@ -22,6 +23,8 @@ PLATFORMS, ) +_LOGGER = logging.getLogger(__name__) + @dataclass class DnsIPRuntimeData: From b6ab41f75e08574a8b4e695229f69f454f8d321a Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Wed, 6 May 2026 12:00:05 +0930 Subject: [PATCH 07/11] Only create resolvers for enabled address families --- homeassistant/components/dnsip/__init__.py | 51 ++++++++++++---------- homeassistant/components/dnsip/sensor.py | 4 +- tests/components/dnsip/test_init.py | 2 +- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 79ea9193a5522..a06088b0c9510 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -30,8 +30,8 @@ class DnsIPRuntimeData: """Runtime data for DNS IP integration.""" - resolver_ipv4: aiodns.DNSResolver - resolver_ipv6: aiodns.DNSResolver + resolver_ipv4: aiodns.DNSResolver | None + resolver_ipv6: aiodns.DNSResolver | None type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData] @@ -40,31 +40,38 @@ class DnsIPRuntimeData: async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool: """Set up DNS IP from a config entry.""" - nameserver_ipv4 = entry.options[CONF_RESOLVER] - nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6] - port_ipv4 = entry.options[CONF_PORT] - port_ipv6 = entry.options[CONF_PORT_IPV6] - - resolver_ipv4 = aiodns.DNSResolver( - nameservers=[nameserver_ipv4], tcp_port=port_ipv4, udp_port=port_ipv4 - ) - resolver_ipv6 = aiodns.DNSResolver( - nameservers=[nameserver_ipv6], tcp_port=port_ipv6, udp_port=port_ipv6 - ) - hostname = entry.data[CONF_HOSTNAME] + resolver_ipv4: aiodns.DNSResolver | None = None + resolver_ipv6: aiodns.DNSResolver | None = None queries: list = [] + if entry.data[CONF_IPV4]: + resolver_ipv4 = aiodns.DNSResolver( + nameservers=[entry.options[CONF_RESOLVER]], + tcp_port=entry.options[CONF_PORT], + udp_port=entry.options[CONF_PORT], + ) queries.append(resolver_ipv4.query(hostname, "A")) + if entry.data[CONF_IPV6]: + resolver_ipv6 = aiodns.DNSResolver( + nameservers=[entry.options[CONF_RESOLVER_IPV6]], + tcp_port=entry.options[CONF_PORT_IPV6], + udp_port=entry.options[CONF_PORT_IPV6], + ) queries.append(resolver_ipv6.query(hostname, "AAAA")) + async def _close_resolvers() -> None: + if resolver_ipv4 is not None: + await resolver_ipv4.close() + if resolver_ipv6 is not None: + await resolver_ipv6.close() + try: async with asyncio.timeout(10): results = await asyncio.gather(*queries, return_exceptions=True) except TimeoutError as err: - await resolver_ipv4.close() - await resolver_ipv6.close() + await _close_resolvers() raise ConfigEntryNotReady( f"DNS lookup timed out for {hostname}: {err}" ) from err @@ -73,8 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo result for result in results if isinstance(result, (TimeoutError, DNSError)) ] if errors and len(errors) == len(results): - await resolver_ipv4.close() - await resolver_ipv6.close() + await _close_resolvers() raise ConfigEntryNotReady( f"DNS lookup failed for {hostname}: {errors[0]}" ) from errors[0] @@ -87,8 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo try: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except Exception: - await resolver_ipv4.close() - await resolver_ipv6.close() + await _close_resolvers() raise return True @@ -99,8 +104,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bo unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - await entry.runtime_data.resolver_ipv4.close() - await entry.runtime_data.resolver_ipv6.close() + if entry.runtime_data.resolver_ipv4 is not None: + await entry.runtime_data.resolver_ipv4.close() + if entry.runtime_data.resolver_ipv6 is not None: + await entry.runtime_data.resolver_ipv6.close() return unload_ok diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 1007f4b1a8578..6616b2d2bb347 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -105,11 +105,13 @@ def __init__( self.hostname = hostname self.port = port self.nameserver = nameserver - self.resolver = ( + resolver = ( entry.runtime_data.resolver_ipv6 if ipv6 else entry.runtime_data.resolver_ipv4 ) + assert resolver is not None + self.resolver = resolver self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 047125243a257..19e11edd098c9 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -287,7 +287,7 @@ async def test_setup_forward_failure(hass: HomeAssistant) -> None: CONF_HOSTNAME: "home-assistant.io", CONF_NAME: "home-assistant.io", CONF_IPV4: True, - CONF_IPV6: False, + CONF_IPV6: True, }, options={ CONF_RESOLVER: "208.67.222.222", From c24c51446bd2aa4490a1118159f3b5940a658541 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Sat, 9 May 2026 12:26:13 +0930 Subject: [PATCH 08/11] Apply maintainer review feedback --- homeassistant/components/dnsip/__init__.py | 64 ++++++++-------------- homeassistant/components/dnsip/sensor.py | 4 +- tests/components/dnsip/test_init.py | 42 -------------- 3 files changed, 25 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index a06088b0c9510..54e562e49b2a3 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -14,8 +14,6 @@ from .const import ( CONF_HOSTNAME, - CONF_IPV4, - CONF_IPV6, CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, @@ -30,8 +28,8 @@ class DnsIPRuntimeData: """Runtime data for DNS IP integration.""" - resolver_ipv4: aiodns.DNSResolver | None - resolver_ipv6: aiodns.DNSResolver | None + resolver_ipv4: aiodns.DNSResolver + resolver_ipv6: aiodns.DNSResolver type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData] @@ -41,37 +39,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo """Set up DNS IP from a config entry.""" hostname = entry.data[CONF_HOSTNAME] - resolver_ipv4: aiodns.DNSResolver | None = None - resolver_ipv6: aiodns.DNSResolver | None = None - queries: list = [] - - if entry.data[CONF_IPV4]: - resolver_ipv4 = aiodns.DNSResolver( - nameservers=[entry.options[CONF_RESOLVER]], - tcp_port=entry.options[CONF_PORT], - udp_port=entry.options[CONF_PORT], - ) - queries.append(resolver_ipv4.query(hostname, "A")) - if entry.data[CONF_IPV6]: - resolver_ipv6 = aiodns.DNSResolver( - nameservers=[entry.options[CONF_RESOLVER_IPV6]], - tcp_port=entry.options[CONF_PORT_IPV6], - udp_port=entry.options[CONF_PORT_IPV6], - ) - queries.append(resolver_ipv6.query(hostname, "AAAA")) + resolver_ipv4 = aiodns.DNSResolver( + nameservers=[entry.options[CONF_RESOLVER]], + tcp_port=entry.options[CONF_PORT], + udp_port=entry.options[CONF_PORT], + ) + resolver_ipv6 = aiodns.DNSResolver( + nameservers=[entry.options[CONF_RESOLVER_IPV6]], + tcp_port=entry.options[CONF_PORT_IPV6], + udp_port=entry.options[CONF_PORT_IPV6], + ) - async def _close_resolvers() -> None: - if resolver_ipv4 is not None: - await resolver_ipv4.close() - if resolver_ipv6 is not None: - await resolver_ipv6.close() + queries = [ + resolver_ipv4.query(hostname, "A"), + resolver_ipv6.query(hostname, "AAAA"), + ] try: async with asyncio.timeout(10): results = await asyncio.gather(*queries, return_exceptions=True) except TimeoutError as err: - await _close_resolvers() + await resolver_ipv4.close() + await resolver_ipv6.close() raise ConfigEntryNotReady( f"DNS lookup timed out for {hostname}: {err}" ) from err @@ -79,8 +69,9 @@ async def _close_resolvers() -> None: errors = [ result for result in results if isinstance(result, (TimeoutError, DNSError)) ] - if errors and len(errors) == len(results): - await _close_resolvers() + if errors and len(errors) == 2: + await resolver_ipv4.close() + await resolver_ipv6.close() raise ConfigEntryNotReady( f"DNS lookup failed for {hostname}: {errors[0]}" ) from errors[0] @@ -90,12 +81,7 @@ async def _close_resolvers() -> None: resolver_ipv6=resolver_ipv6, ) - try: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - except Exception: - await _close_resolvers() - raise - + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -104,10 +90,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bo unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - if entry.runtime_data.resolver_ipv4 is not None: - await entry.runtime_data.resolver_ipv4.close() - if entry.runtime_data.resolver_ipv6 is not None: - await entry.runtime_data.resolver_ipv6.close() + await entry.runtime_data.resolver_ipv4.close() + await entry.runtime_data.resolver_ipv6.close() return unload_ok diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 6616b2d2bb347..1007f4b1a8578 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -105,13 +105,11 @@ def __init__( self.hostname = hostname self.port = port self.nameserver = nameserver - resolver = ( + self.resolver = ( entry.runtime_data.resolver_ipv6 if ipv6 else entry.runtime_data.resolver_ipv4 ) - assert resolver is not None - self.resolver = resolver self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 19e11edd098c9..828dbde81f801 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -277,45 +277,3 @@ async def test_setup_dns_timeout(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_forward_failure(hass: HomeAssistant) -> None: - """Test resolvers are closed when platform forwarding raises.""" - - entry = MockConfigEntry( - domain=DOMAIN, - source=SOURCE_USER, - data={ - CONF_HOSTNAME: "home-assistant.io", - CONF_NAME: "home-assistant.io", - CONF_IPV4: True, - CONF_IPV6: True, - }, - options={ - CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:119:53::53", - CONF_PORT: 53, - CONF_PORT_IPV6: 53, - }, - entry_id="1", - unique_id="home-assistant.io", - ) - entry.add_to_hass(hass) - - dns_mock_ipv4 = RetrieveDNS() - dns_mock_ipv6 = RetrieveDNS() - with ( - patch( - "homeassistant.components.dnsip.aiodns.DNSResolver", - side_effect=[dns_mock_ipv4, dns_mock_ipv6], - ), - patch.object( - hass.config_entries, - "async_forward_entry_setups", - side_effect=Exception("forward failed"), - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_ERROR - assert dns_mock_ipv4._closed - assert dns_mock_ipv6._closed From 2447ee6b7966db8c819d413b337e6ac7a4943be0 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Sat, 9 May 2026 12:37:03 +0930 Subject: [PATCH 09/11] Fix gather typing and trailing whitespace --- homeassistant/components/dnsip/__init__.py | 11 +++++------ tests/components/dnsip/test_init.py | 2 -- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 54e562e49b2a3..8f17614a7119a 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -51,14 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo udp_port=entry.options[CONF_PORT_IPV6], ) - queries = [ - resolver_ipv4.query(hostname, "A"), - resolver_ipv6.query(hostname, "AAAA"), - ] - try: async with asyncio.timeout(10): - results = await asyncio.gather(*queries, return_exceptions=True) + results = await asyncio.gather( + resolver_ipv4.query(hostname, "A"), + resolver_ipv6.query(hostname, "AAAA"), + return_exceptions=True, + ) except TimeoutError as err: await resolver_ipv4.close() await resolver_ipv6.close() diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 828dbde81f801..8e4e8f5f77f02 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -275,5 +275,3 @@ async def test_setup_dns_timeout(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY - - From ec35e412f2d305b3efcf0f003ec5edd13694fd50 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Tue, 12 May 2026 23:22:40 +0930 Subject: [PATCH 10/11] Apply maintainer review feedback --- homeassistant/components/dnsip/__init__.py | 67 ++++++++++++++-------- homeassistant/components/dnsip/sensor.py | 33 ++++++----- 2 files changed, 61 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 8f17614a7119a..69c385643f15a 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -6,6 +6,7 @@ import aiodns from aiodns.error import DNSError +from pycares import AresError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT @@ -14,6 +15,8 @@ from .const import ( CONF_HOSTNAME, + CONF_IPV4, + CONF_IPV6, CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, @@ -28,8 +31,8 @@ class DnsIPRuntimeData: """Runtime data for DNS IP integration.""" - resolver_ipv4: aiodns.DNSResolver - resolver_ipv6: aiodns.DNSResolver + resolver_ipv4: aiodns.DNSResolver | None + resolver_ipv6: aiodns.DNSResolver | None type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData] @@ -39,38 +42,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo """Set up DNS IP from a config entry.""" hostname = entry.data[CONF_HOSTNAME] + resolver_ipv4: aiodns.DNSResolver | None = None + resolver_ipv6: aiodns.DNSResolver | None = None + queries: list = [] + + if entry.data[CONF_IPV4]: + resolver_ipv4 = aiodns.DNSResolver( + nameservers=[entry.options[CONF_RESOLVER]], + tcp_port=entry.options[CONF_PORT], + udp_port=entry.options[CONF_PORT], + ) + queries.append(resolver_ipv4.query(hostname, "A")) - resolver_ipv4 = aiodns.DNSResolver( - nameservers=[entry.options[CONF_RESOLVER]], - tcp_port=entry.options[CONF_PORT], - udp_port=entry.options[CONF_PORT], - ) - resolver_ipv6 = aiodns.DNSResolver( - nameservers=[entry.options[CONF_RESOLVER_IPV6]], - tcp_port=entry.options[CONF_PORT_IPV6], - udp_port=entry.options[CONF_PORT_IPV6], - ) + if entry.data[CONF_IPV6]: + resolver_ipv6 = aiodns.DNSResolver( + nameservers=[entry.options[CONF_RESOLVER_IPV6]], + tcp_port=entry.options[CONF_PORT_IPV6], + udp_port=entry.options[CONF_PORT_IPV6], + ) + queries.append(resolver_ipv6.query(hostname, "AAAA")) + + async def _close_resolvers() -> None: + if resolver_ipv4 is not None: + await resolver_ipv4.close() + if resolver_ipv6 is not None: + await resolver_ipv6.close() try: async with asyncio.timeout(10): - results = await asyncio.gather( - resolver_ipv4.query(hostname, "A"), - resolver_ipv6.query(hostname, "AAAA"), - return_exceptions=True, - ) + results = await asyncio.gather(*queries, return_exceptions=True) except TimeoutError as err: - await resolver_ipv4.close() - await resolver_ipv6.close() + await _close_resolvers() raise ConfigEntryNotReady( f"DNS lookup timed out for {hostname}: {err}" ) from err errors = [ - result for result in results if isinstance(result, (TimeoutError, DNSError)) + result + for result in results + if isinstance( + result, (TimeoutError, DNSError, AresError, asyncio.CancelledError) + ) ] - if errors and len(errors) == 2: - await resolver_ipv4.close() - await resolver_ipv6.close() + if errors and len(errors) == len(results): + await _close_resolvers() raise ConfigEntryNotReady( f"DNS lookup failed for {hostname}: {errors[0]}" ) from errors[0] @@ -89,8 +104,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bo unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - await entry.runtime_data.resolver_ipv4.close() - await entry.runtime_data.resolver_ipv6.close() + if entry.runtime_data.resolver_ipv4 is not None: + await entry.runtime_data.resolver_ipv4.close() + if entry.runtime_data.resolver_ipv6 is not None: + await entry.runtime_data.resolver_ipv6.close() return unload_ok diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 1007f4b1a8578..f1762d5b0c565 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -105,11 +105,6 @@ def __init__( self.hostname = hostname self.port = port self.nameserver = nameserver - self.resolver = ( - entry.runtime_data.resolver_ipv6 - if ipv6 - else entry.runtime_data.resolver_ipv4 - ) self.querytype: Literal["A", "AAAA"] = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { @@ -124,31 +119,41 @@ def __init__( name=name, ) + @property + def _resolver(self) -> aiodns.DNSResolver: + """Return the active DNS resolver from runtime data.""" + resolver = ( + self.entry.runtime_data.resolver_ipv6 + if self.ipv6 + else self.entry.runtime_data.resolver_ipv4 + ) + assert resolver is not None + return resolver + def create_dns_resolver(self) -> None: - """Create the DNS resolver.""" - self.resolver = aiodns.DNSResolver( + """Create a new DNS resolver and store it on runtime data.""" + new_resolver = aiodns.DNSResolver( nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port ) - if self.ipv6: - self.entry.runtime_data.resolver_ipv6 = self.resolver + self.entry.runtime_data.resolver_ipv6 = new_resolver else: - self.entry.runtime_data.resolver_ipv4 = self.resolver + self.entry.runtime_data.resolver_ipv4 = new_resolver async def async_update(self) -> None: """Get the current DNS IP address for hostname.""" - if self.resolver._closed: # noqa: SLF001 + if self._resolver._closed: # noqa: SLF001 self.create_dns_resolver() response = None try: async with asyncio.timeout(10): - response = await self.resolver.query(self.hostname, self.querytype) + response = await self._resolver.query(self.hostname, self.querytype) except TimeoutError as err: _LOGGER.debug("Timeout while resolving host: %s", err) - await self.resolver.close() + await self._resolver.close() except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) - await self.resolver.close() + await self._resolver.close() if response: sorted_ips = sort_ips( From bba421d1b1f8a70cbc3352edc33750c1f72b0d02 Mon Sep 17 00:00:00 2001 From: Phil-Rad Date: Fri, 15 May 2026 13:03:13 +0930 Subject: [PATCH 11/11] Apply maintainer review feedback --- homeassistant/components/dnsip/sensor.py | 5 +++-- tests/components/dnsip/test_init.py | 26 ++++++++++++++++++------ tests/components/dnsip/test_sensor.py | 6 ++---- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index f1762d5b0c565..ca965f330195e 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from ipaddress import IPv4Address, IPv6Address import logging -from typing import Literal +from typing import TYPE_CHECKING, Literal import aiodns from aiodns.error import DNSError @@ -127,7 +127,8 @@ def _resolver(self) -> aiodns.DNSResolver: if self.ipv6 else self.entry.runtime_data.resolver_ipv4 ) - assert resolver is not None + if TYPE_CHECKING: + assert resolver is not None return resolver def create_dns_resolver(self) -> None: diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 8e4e8f5f77f02..db32a22895818 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -1,8 +1,11 @@ """Test for DNS IP integration Init.""" +import asyncio from unittest.mock import patch from aiodns.error import DNSError +from pycares import AresError +import pytest from homeassistant.components.dnsip.const import ( CONF_HOSTNAME, @@ -47,7 +50,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - side_effect=[RetrieveDNS(), RetrieveDNS()], + return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -163,7 +166,7 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - side_effect=[RetrieveDNS(), RetrieveDNS()], + return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -172,7 +175,16 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.MIGRATION_ERROR -async def test_setup_dns_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "error", + [ + TimeoutError(), + DNSError(), + AresError(), + asyncio.CancelledError(), + ], +) +async def test_setup_dns_error(hass: HomeAssistant, error: Exception) -> None: """Test setup raises ConfigEntryNotReady when DNS lookup fails.""" entry = MockConfigEntry( @@ -197,7 +209,7 @@ async def test_setup_dns_error(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - side_effect=[RetrieveDNS(error=DNSError()), RetrieveDNS(error=DNSError())], + return_value=RetrieveDNS(error=error), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -206,7 +218,7 @@ async def test_setup_dns_error(hass: HomeAssistant) -> None: async def test_setup_ipv6_only(hass: HomeAssistant) -> None: - """Test setup with only IPv6 enabled exercises the IPv6 lookup branch.""" + """Test setup with only IPv6 enabled creates only the IPv6 entity.""" entry = MockConfigEntry( domain=DOMAIN, @@ -230,12 +242,14 @@ async def test_setup_ipv6_only(hass: HomeAssistant) -> None: with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - side_effect=[RetrieveDNS(), RetrieveDNS()], + return_value=RetrieveDNS(), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED + assert hass.states.get("sensor.home_assistant_io_ipv6") is not None + assert hass.states.get("sensor.home_assistant_io") is None async def test_setup_dns_timeout(hass: HomeAssistant) -> None: diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index 58d9f9f8f301e..ef8f7809dd641 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -136,10 +136,9 @@ async def test_sensor_no_response( entry.add_to_hass(hass) dns_mock_ipv4 = RetrieveDNS() - dns_mock_ipv6 = RetrieveDNS() with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - side_effect=[dns_mock_ipv4, dns_mock_ipv6], + return_value=dns_mock_ipv4, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -196,10 +195,9 @@ async def test_sensor_timeout( entry.add_to_hass(hass) dns_mock_ipv4 = RetrieveDNS() - dns_mock_ipv6 = RetrieveDNS() with patch( "homeassistant.components.dnsip.aiodns.DNSResolver", - side_effect=[dns_mock_ipv4, dns_mock_ipv6], + return_value=dns_mock_ipv4, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done()