Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion homeassistant/components/config/config_entries.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/daikin/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."]
}
33 changes: 18 additions & 15 deletions homeassistant/components/whois/config_flow.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 18 additions & 9 deletions homeassistant/components/whois/coordinator.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion homeassistant/components/whois/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/whois/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
18 changes: 7 additions & 11 deletions homeassistant/components/whois/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/helpers/device_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 51 additions & 0 deletions tests/components/config/test_config_entries.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test config entries API."""

import asyncio
from collections.abc import Generator
from http import HTTPStatus
from typing import Any
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 10 additions & 7 deletions tests/components/whois/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -42,17 +42,20 @@ 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"
domain.admin = "admin@example.com"
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"
Expand All @@ -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:
Expand All @@ -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"
Expand All @@ -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

Expand Down
Loading
Loading