From a09bade464d046a4d74d27112faad4d4491dfcf8 Mon Sep 17 00:00:00 2001 From: Tomasz Date: Thu, 13 Aug 2026 17:36:24 +0200 Subject: [PATCH 1/4] Replace deprecated whois package with whoisdomain (#166689) Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- homeassistant/components/whois/config_flow.py | 33 ++++++----- homeassistant/components/whois/coordinator.py | 27 ++++++--- homeassistant/components/whois/diagnostics.py | 2 +- homeassistant/components/whois/manifest.json | 4 +- homeassistant/components/whois/sensor.py | 18 +++--- requirements_all.txt | 2 +- tests/components/whois/conftest.py | 17 +++--- tests/components/whois/test_config_flow.py | 55 ++++++++++++++----- tests/components/whois/test_init.py | 24 +++++--- 9 files changed, 113 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/whois/config_flow.py b/homeassistant/components/whois/config_flow.py index edbd76570269b..b5ad9cc4fa525 100644 --- a/homeassistant/components/whois/config_flow.py +++ b/homeassistant/components/whois/config_flow.py @@ -1,16 +1,17 @@ """Config flow to configure the Whois integration.""" +from functools import partial from typing import Any, override import voluptuous as vol -import whois -from whois.exceptions import ( - FailedParsingWhoisOutput, - UnknownDateFormat, - UnknownTld, - WhoisCommandFailed, - WhoisPrivateRegistry, - WhoisQuotaExceeded, +import whoisdomain +from whoisdomain.exceptions import ( + FailedParsingWhoisOutputError, + UnknownDateFormatError, + UnknownTldError, + WhoisCommandFailedError, + WhoisPrivateRegistryError, + WhoisQuotaExceededError, ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -40,18 +41,20 @@ async def async_step_user( self._abort_if_unique_id_configured() try: - await self.hass.async_add_executor_job(whois.query, domain) - except UnknownTld: + await self.hass.async_add_executor_job( + partial(whoisdomain.query, domain, whoisOnly=True) + ) + except UnknownTldError: errors["base"] = "unknown_tld" - except WhoisCommandFailed: + except WhoisCommandFailedError: errors["base"] = "whois_command_failed" - except FailedParsingWhoisOutput: + except FailedParsingWhoisOutputError: errors["base"] = "unexpected_response" - except UnknownDateFormat: + except UnknownDateFormatError: errors["base"] = "unknown_date_format" - except WhoisPrivateRegistry: + except WhoisPrivateRegistryError: errors["base"] = "private_registry" - except WhoisQuotaExceeded: + except WhoisQuotaExceededError: errors["base"] = "quota_exceeded" else: return self.async_create_entry( diff --git a/homeassistant/components/whois/coordinator.py b/homeassistant/components/whois/coordinator.py index 4a5b7b0b1f05c..cbdaf13021595 100644 --- a/homeassistant/components/whois/coordinator.py +++ b/homeassistant/components/whois/coordinator.py @@ -1,13 +1,14 @@ """DataUpdateCoordinator for the Whois integration.""" +from functools import partial from typing import override -from whois import Domain, query as whois_query -from whois.exceptions import ( - FailedParsingWhoisOutput, - UnknownDateFormat, - UnknownTld, - WhoisCommandFailed, +from whoisdomain import Domain, query as whoisdomain_query +from whoisdomain.exceptions import ( + FailedParsingWhoisOutputError, + UnknownDateFormatError, + UnknownTldError, + WhoisCommandFailedError, ) from homeassistant.config_entries import ConfigEntry @@ -40,9 +41,17 @@ async def _async_update_data(self) -> Domain | None: """Query WHOIS for domain information.""" try: return await self.hass.async_add_executor_job( - whois_query, self.config_entry.data[CONF_DOMAIN] + partial( + whoisdomain_query, + self.config_entry.data[CONF_DOMAIN], + whoisOnly=True, + ) ) - except UnknownTld as ex: + except UnknownTldError as ex: raise UpdateFailed("Could not set up whois, TLD is unknown") from ex - except (FailedParsingWhoisOutput, WhoisCommandFailed, UnknownDateFormat) as ex: + except ( + FailedParsingWhoisOutputError, + WhoisCommandFailedError, + UnknownDateFormatError, + ) as ex: raise UpdateFailed("An error occurred during WHOIS lookup") from ex diff --git a/homeassistant/components/whois/diagnostics.py b/homeassistant/components/whois/diagnostics.py index 114b0163e61ef..36ed3c94f0d65 100644 --- a/homeassistant/components/whois/diagnostics.py +++ b/homeassistant/components/whois/diagnostics.py @@ -16,7 +16,7 @@ async def async_get_config_entry_diagnostics( return { "creation_date": data.creation_date, "expiration_date": data.expiration_date, - "last_updated": data.last_updated, + "last_updated": data.updated_date, "status": data.status, "statuses": data.statuses, "dnssec": data.dnssec, diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json index 75cfd6d83f8b0..5cf8d3db58b17 100644 --- a/homeassistant/components/whois/manifest.json +++ b/homeassistant/components/whois/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/whois", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["whois"], - "requirements": ["whois==0.9.27"] + "loggers": ["whoisdomain"], + "requirements": ["whoisdomain==2.20260806.3"] } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 721b464179d03..9fd0752a12614 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -3,9 +3,9 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime -from typing import cast, override +from typing import override -from whois import Domain +from whoisdomain import Domain from homeassistant.components.sensor import ( SensorDeviceClass, @@ -41,11 +41,7 @@ def _days_until_expiration(domain: Domain) -> int | None: """Calculate days left until domain expires.""" if domain.expiration_date is None: return None - # We need to cast here, as (unlike Pyright) mypy isn't able to determine the type. - return cast( - int, - (domain.expiration_date - dt_util.utcnow().replace(tzinfo=None)).days, - ) + return (domain.expiration_date - dt_util.utcnow().replace(tzinfo=None)).days def _ensure_timezone(timestamp: datetime | None) -> datetime | None: @@ -113,7 +109,7 @@ def _get_status_type(status: str | None) -> str | None: translation_key="last_updated", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda domain: _ensure_timezone(domain.last_updated), + value_fn=lambda domain: _ensure_timezone(domain.updated_date), ), WhoisSensorEntityDescription( key="owner", @@ -207,7 +203,7 @@ def native_value(self) -> datetime | int | str | None: @property @override - def extra_state_attributes(self) -> dict[str, int | float | None] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the monitored installation.""" # Only add attributes to the original sensor @@ -224,8 +220,8 @@ def extra_state_attributes(self) -> dict[str, int | float | None] | None: if name_servers := self.coordinator.data.name_servers: attrs[ATTR_NAME_SERVERS] = " ".join(name_servers) - if last_updated := self.coordinator.data.last_updated: - attrs[ATTR_UPDATED] = last_updated.isoformat() + if updated_date := self.coordinator.data.updated_date: + attrs[ATTR_UPDATED] = updated_date.isoformat() if registrar := self.coordinator.data.registrar: attrs[ATTR_REGISTRAR] = registrar diff --git a/requirements_all.txt b/requirements_all.txt index 3c0abcc201aab..6298aef89193e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3385,7 +3385,7 @@ weheat==2026.4.8 whirlpool-sixth-sense==1.3.1 # homeassistant.components.whois -whois==0.9.27 +whoisdomain==2.20260806.3 # homeassistant.components.wiffi wiffi==1.1.2 diff --git a/tests/components/whois/conftest.py b/tests/components/whois/conftest.py index 5f4308b4e2564..d2612a4bf8e6b 100644 --- a/tests/components/whois/conftest.py +++ b/tests/components/whois/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from datetime import datetime from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -42,9 +42,12 @@ def mock_whois() -> Generator[MagicMock]: """Return a mocked query.""" with ( patch( - "homeassistant.components.whois.coordinator.whois_query", + "homeassistant.components.whois.coordinator.whoisdomain_query", ) as whois_mock, - patch("homeassistant.components.whois.config_flow.whois.query", new=whois_mock), + patch( + "homeassistant.components.whois.config_flow.whoisdomain.query", + new=whois_mock, + ), ): domain = whois_mock.return_value domain.abuse_contact = "abuse@example.com" @@ -52,7 +55,7 @@ def mock_whois() -> Generator[MagicMock]: domain.creation_date = datetime(2019, 1, 1, 0, 0, 0) domain.dnssec = True domain.expiration_date = datetime(2023, 1, 1, 0, 0, 0) - domain.last_updated = datetime( + domain.updated_date = datetime( 2022, 1, 1, 0, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Amsterdam") ) domain.name = "home-assistant.io" @@ -67,7 +70,7 @@ def mock_whois() -> Generator[MagicMock]: @pytest.fixture -def mock_whois_missing_some_attrs() -> Generator[Mock]: +def mock_whois_missing_some_attrs() -> Generator[Any]: """Return a mocked query that only sets admin.""" class LimitedWhoisMock: @@ -78,7 +81,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.creation_date = datetime(2019, 1, 1, 0, 0, 0) self.dnssec = True self.expiration_date = datetime(2023, 1, 1, 0, 0, 0) - self.last_updated = datetime( + self.updated_date = datetime( 2022, 1, 1, 0, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Amsterdam") ) self.name = "home-assistant.io" @@ -88,7 +91,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.statuses = ["OK"] with patch( - "homeassistant.components.whois.coordinator.whois_query", LimitedWhoisMock + "homeassistant.components.whois.coordinator.whoisdomain_query", LimitedWhoisMock ) as whois_mock: yield whois_mock diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py index 6ab02887be29f..e6b46bd07bec7 100644 --- a/tests/components/whois/test_config_flow.py +++ b/tests/components/whois/test_config_flow.py @@ -1,16 +1,16 @@ """Tests for the Whois config flow.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call import pytest from syrupy.assertion import SnapshotAssertion -from whois.exceptions import ( - FailedParsingWhoisOutput, - UnknownDateFormat, - UnknownTld, - WhoisCommandFailed, - WhoisPrivateRegistry, - WhoisQuotaExceeded, +from whoisdomain.exceptions import ( + FailedParsingWhoisOutputError, + UnknownDateFormatError, + UnknownTldError, + WhoisCommandFailedError, + WhoisPrivateRegistryError, + WhoisQuotaExceededError, ) from homeassistant.components.whois.const import DOMAIN @@ -26,6 +26,7 @@ async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_whois: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" @@ -44,18 +45,39 @@ async def test_full_user_flow( assert result2.get("type") is FlowResultType.CREATE_ENTRY assert result2 == snapshot + mock_whois.assert_called_once_with("example.com", whoisOnly=True) assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( ("throw", "reason"), [ - (UnknownTld, "unknown_tld"), - (FailedParsingWhoisOutput, "unexpected_response"), - (UnknownDateFormat, "unknown_date_format"), - (WhoisCommandFailed, "whois_command_failed"), - (WhoisPrivateRegistry, "private_registry"), - (WhoisQuotaExceeded, "quota_exceeded"), + pytest.param(UnknownTldError, "unknown_tld", id="UnknownTld-unknown_tld"), + pytest.param( + FailedParsingWhoisOutputError, + "unexpected_response", + id="FailedParsingWhoisOutput-unexpected_response", + ), + pytest.param( + UnknownDateFormatError, + "unknown_date_format", + id="UnknownDateFormat-unknown_date_format", + ), + pytest.param( + WhoisCommandFailedError, + "whois_command_failed", + id="WhoisCommandFailed-whois_command_failed", + ), + pytest.param( + WhoisPrivateRegistryError, + "private_registry", + id="WhoisPrivateRegistry-private_registry", + ), + pytest.param( + WhoisQuotaExceededError, + "quota_exceeded", + id="WhoisQuotaExceeded-quota_exceeded", + ), ], ) async def test_full_flow_with_error( @@ -90,6 +112,7 @@ async def test_full_flow_with_error( assert len(mock_setup_entry.mock_calls) == 0 assert len(mock_whois.mock_calls) == 1 + mock_whois.assert_called_once_with("example.com", whoisOnly=True) mock_whois.side_effect = None result3 = await hass.config_entries.flow.async_configure( @@ -102,6 +125,10 @@ async def test_full_flow_with_error( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_whois.mock_calls) == 2 + assert mock_whois.mock_calls == [ + call("example.com", whoisOnly=True), + call("example.com", whoisOnly=True), + ] @pytest.mark.usefixtures("mock_whois") diff --git a/tests/components/whois/test_init.py b/tests/components/whois/test_init.py index 0765661c5742d..437e3044a5472 100644 --- a/tests/components/whois/test_init.py +++ b/tests/components/whois/test_init.py @@ -3,11 +3,11 @@ from unittest.mock import MagicMock import pytest -from whois.exceptions import ( - FailedParsingWhoisOutput, - UnknownDateFormat, - UnknownTld, - WhoisCommandFailed, +from whoisdomain.exceptions import ( + FailedParsingWhoisOutputError, + UnknownDateFormatError, + UnknownTldError, + WhoisCommandFailedError, ) from homeassistant.components.whois.const import DOMAIN @@ -28,18 +28,24 @@ async def test_load_unload_config_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - assert len(mock_whois.mock_calls) == 1 + mock_whois.assert_called_once_with("home-assistant.io", whoisOnly=True) await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert not hass.data.get(DOMAIN) - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + state: ConfigEntryState = mock_config_entry.state + assert state is ConfigEntryState.NOT_LOADED @pytest.mark.parametrize( "side_effect", - [FailedParsingWhoisOutput, UnknownDateFormat, UnknownTld, WhoisCommandFailed], + [ + FailedParsingWhoisOutputError, + UnknownDateFormatError, + UnknownTldError, + WhoisCommandFailedError, + ], ) async def test_error_handling( hass: HomeAssistant, @@ -55,4 +61,4 @@ async def test_error_handling( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert len(mock_whois.mock_calls) == 1 + mock_whois.assert_called_once_with("home-assistant.io", whoisOnly=True) From 57f2e39028cc7f3f8300bf65fac2548d83ab390b Mon Sep 17 00:00:00 2001 From: Dmitry <45711841+darkdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:40:45 +0300 Subject: [PATCH 2/4] Shield config entry removal from client disconnect (#178207) --- .../components/config/config_entries.py | 13 ++++- .../components/config/test_config_entries.py | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index f569f7ca421c6..6c13944e7814e 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,5 +1,6 @@ """Http views to control the config manager.""" +from asyncio import shield from collections.abc import Callable from http import HTTPStatus import logging @@ -110,8 +111,18 @@ async def delete(self, request: web.Request, entry_id: str) -> web.Response: hass = request.app[KEY_HASS] + # Shield the removal from cancellation on connection drop, otherwise the + # entry is dropped from memory but never saved or cleaned up. The task is + # created through hass so a strong reference is held for its lifetime, + # which keeps it from being garbage collected once the request handler + # has gone away. + remove_task = hass.async_create_task( + hass.config_entries.async_remove(entry_id), + f"config entry remove {entry_id}", + ) + try: - result = await hass.config_entries.async_remove(entry_id) + result = await shield(remove_task) except config_entries.UnknownEntry: return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 3077c55f06091..71dfad0a1e816 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1,5 +1,6 @@ """Test config entries API.""" +import asyncio from collections.abc import Generator from http import HTTPStatus from typing import Any @@ -13,6 +14,7 @@ from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import DOMAIN, config_entries +from homeassistant.components.http import KEY_HASS from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback @@ -269,6 +271,55 @@ async def test_remove_entry(hass: HomeAssistant, client: TestClient) -> None: assert len(hass.config_entries.async_entries()) == 0 +async def test_remove_entry_survives_client_disconnect( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: + """Test a client disconnect does not truncate the removal. + + The HTTP runner is created with handler_cancellation=True, so a disconnect + cancels the request handler. Removal deletes the entry from memory before + awaiting the integration, so an unshielded cancel leaves the entry on disk + and its registry rows behind. + """ + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + removing = asyncio.Event() + disconnected = asyncio.Event() + original_async_remove = core_ce.ConfigEntry.async_remove + + async def blocking_async_remove(self: core_ce.ConfigEntry, *args: Any) -> None: + """Stall inside the removal, so the cancel lands on this await.""" + removing.set() + await disconnected.wait() + await original_async_remove(self, *args) + + view = config_entries.ConfigManagerEntryResourceView() + request = Mock() + request.__getitem__ = Mock(side_effect={"hass_user": hass_admin_user}.__getitem__) + request.app = {KEY_HASS: hass} + + with ( + patch.object(core_ce.ConfigEntry, "async_remove", blocking_async_remove), + patch.object(hass.config_entries, "_async_schedule_save") as mock_schedule_save, + ): + task = hass.async_create_task(view.delete(request, entry.entry_id)) + await removing.wait() + + # The client goes away mid-removal. + task.cancel() + disconnected.set() + + with pytest.raises(asyncio.CancelledError): + await task + + await hass.async_block_till_done() + + # The rest of the removal must still have run. + assert mock_schedule_save.called + assert hass.config_entries.async_entries() == [] + + async def test_reload_entry(hass: HomeAssistant, client: TestClient) -> None: """Test reloading an entry via the API.""" entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) From d4b278e9f50fcd3a23291dc4079b9fd03b0e55e3 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 13 Aug 2026 22:15:02 +0200 Subject: [PATCH 3/4] Bump pydaikin to 2.19.0 (#179087) --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index c406cf0ddb008..c86b0ca39acdd 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.18.5"], + "requirements": ["pydaikin==2.19.0"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6298aef89193e..abe1d41786dd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2116,7 +2116,7 @@ pycsspeechtts==1.0.8 pycync==0.5.0 # homeassistant.components.daikin -pydaikin==2.18.5 +pydaikin==2.19.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 From 82807d160491d2edd236639dddfe39a46a18c715 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:17:52 +0200 Subject: [PATCH 4/4] Reapply comment in async_get_or_create in device registry (#179076) --- homeassistant/helpers/device_registry.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 82b36a3fa38b7..61773aaaaa304 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2256,7 +2256,8 @@ def async_get_or_create( # noqa: C901 else: connections = _normalize_connections(connections) - # A child is referenced via parent_device_id, not adopted by a device info + # We do not allow registering a device without parent_device_id if the + # identifiers match an existing child. if ( matched_child_device := self.child_devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id