Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Whois component maintenance, PR 1 of 3: Light refactoring, adding abstraction #117749

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from
Draft
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
16 changes: 5 additions & 11 deletions homeassistant/components/whois/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,14 @@

from __future__ import annotations

from whois import Domain, query as whois_query
from whois.exceptions import (
FailedParsingWhoisOutput,
UnknownDateFormat,
UnknownTld,
WhoisCommandFailed,
)

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from . import helper
from .const import DOMAIN, LOGGER, PLATFORMS, SCAN_INTERVAL
from .helper import Domain


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Expand All @@ -25,11 +19,11 @@ async def _async_query_domain() -> Domain | None:
"""Query WHOIS for domain information."""
try:
return await hass.async_add_executor_job(
whois_query, entry.data[CONF_DOMAIN]
helper.query, entry.data[CONF_DOMAIN]
)
except UnknownTld as ex:
except helper.WhoisUnknownTLD as ex:
raise UpdateFailed("Could not set up whois, TLD is unknown") from ex
except (FailedParsingWhoisOutput, WhoisCommandFailed, UnknownDateFormat) as ex:
except helper.GenericWhoisError as ex:
raise UpdateFailed("An error occurred during WHOIS lookup") from ex

coordinator: DataUpdateCoordinator[Domain | None] = DataUpdateCoordinator(
Expand Down
18 changes: 4 additions & 14 deletions homeassistant/components/whois/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,11 @@
from typing import Any

import voluptuous as vol
import whois
from whois.exceptions import (
FailedParsingWhoisOutput,
UnknownDateFormat,
UnknownTld,
WhoisCommandFailed,
)

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DOMAIN

from . import helper
from .const import DOMAIN


Expand All @@ -39,15 +33,11 @@ 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(helper.query, domain)
except helper.WhoisUnknownTLD:
errors["base"] = "unknown_tld"
except WhoisCommandFailed:
except helper.GenericWhoisError:
errors["base"] = "whois_command_failed"
except FailedParsingWhoisOutput:
errors["base"] = "unexpected_response"
except UnknownDateFormat:
errors["base"] = "unknown_date_format"
else:
return self.async_create_entry(
title=self.imported_name or user_input[CONF_DOMAIN],
Expand Down
3 changes: 1 addition & 2 deletions homeassistant/components/whois/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@

from typing import Any

from whois import Domain

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import DOMAIN
from .helper import Domain


async def async_get_config_entry_diagnostics(
Expand Down
64 changes: 64 additions & 0 deletions homeassistant/components/whois/helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""A helper class that abstracts away the usage of external whois libraries."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an uncommon level of abstraction for Home Assistant. This is basically why we use libraries.


from dataclasses import dataclass
from typing import Any

import whois


@dataclass(kw_only=True)
class Domain:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be typing from upstream

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look at the final code after all 3 planned PRs listed in the description. I believe it will help you understand why I decided to do it with a helper, instead of putting all the processing logic directly in HA classes.

https://github.com/mkrasowski/homeassistant-core/blob/replace-unmaintained-whois-library/homeassistant/components/whois/helper.py

"""A class internally representing a domain."""

admin: Any = None
creation_date: Any = None
dnssec: Any = None
expiration_date: Any = None
last_updated: Any = None
name_servers: Any = None
owner: Any = None
registrar: Any = None
reseller: Any = None
registrant: Any = None
status: Any = None
statuses: Any = None


class WhoisUnknownTLD(Exception):
"""Exception class when unknown TLD encountered."""


class GenericWhoisError(Exception):
"""Exception class for all other exceptions originating from the external whois library."""
Comment on lines +27 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should come from upstream



def query(domain: str) -> Domain | None:
"""Wrap around the external whois library call and return internal domain representation."""

wh = None
try:
wh = whois.query(domain)
except whois.exceptions.UnknownTld as ex:
raise WhoisUnknownTLD from ex
except Exception as ex:
raise GenericWhoisError from ex
else:
# backward-compatibility
if wh is None:
return None

# field mapping here
return Domain(
admin=wh.admin,
creation_date=wh.creation_date,
dnssec=wh.dnssec,
expiration_date=wh.expiration_date,
last_updated=wh.last_updated,
name_servers=wh.name_servers,
owner=wh.owner,
registrar=wh.registrar,
reseller=wh.reseller,
registrant=wh.registrant,
status=wh.status,
statuses=wh.statuses,
)
3 changes: 1 addition & 2 deletions homeassistant/components/whois/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
from datetime import UTC, datetime
from typing import cast

from whois import Domain

from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
Expand All @@ -26,6 +24,7 @@
from homeassistant.util import dt as dt_util

from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN
from .helper import Domain


@dataclass(frozen=True, kw_only=True)
Expand Down
2 changes: 0 additions & 2 deletions homeassistant/components/whois/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
}
},
"error": {
"unexpected_response": "Unexpected response from whois server",
"unknown_date_format": "Unknown date format in whois server response",
"unknown_tld": "The given TLD is unknown or not available to this integration",
"whois_command_failed": "Whois command failed: could not retrieve whois information"
},
Expand Down
57 changes: 18 additions & 39 deletions tests/components/whois/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,9 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]:
@pytest.fixture
def mock_whois() -> Generator[MagicMock, None, None]:
"""Return a mocked query."""
with (
patch(
"homeassistant.components.whois.whois_query",
) as whois_mock,
patch("homeassistant.components.whois.config_flow.whois.query", new=whois_mock),
):
with patch(
"homeassistant.components.whois.helper.whois.query",
) as whois_mock:
domain = whois_mock.return_value
domain.abuse_contact = "abuse@example.com"
domain.admin = "admin@example.com"
Expand All @@ -69,49 +66,31 @@ def mock_whois() -> Generator[MagicMock, None, None]:

@pytest.fixture
def mock_whois_missing_some_attrs() -> Generator[Mock, None, None]:
"""Return a mocked query that only sets admin."""

class LimitedWhoisMock:
"""A limited mock of whois_query."""

def __init__(self, *args, **kwargs):
"""Mock only attributes the library always sets being available."""
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(
2022, 1, 1, 0, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Amsterdam")
)
self.name = "home-assistant.io"
self.name_servers = ["ns1.example.com", "ns2.example.com"]
self.registrar = "My Registrar"
self.status = "OK"
self.statuses = ["OK"]

"""Return a mocked query that only sets one attribute."""
with patch(
"homeassistant.components.whois.whois_query", LimitedWhoisMock
"homeassistant.components.whois.helper.whois.query",
) as whois_mock:
yield whois_mock
domain = whois_mock.return_value
domain.last_updated = datetime(
2022, 1, 1, 0, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Amsterdam")
)
yield domain


@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_whois: MagicMock
) -> MockConfigEntry:
"""Set up thewhois integration for testing."""
mock_config_entry.add_to_hass(hass)

await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

return mock_config_entry
def mock_whois_empty() -> Generator[Mock, None, None]:
"""Mock an empty response from the whois library."""
with patch(
"homeassistant.components.whois.helper.whois.query",
) as whois_mock:
whois_mock.return_value = None
yield whois_mock


@pytest.fixture
async def init_integration_missing_some_attrs(
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_whois_missing_some_attrs: MagicMock,
) -> MockConfigEntry:
"""Set up thewhois integration for testing."""
mock_config_entry.add_to_hass(hass)
Expand Down
4 changes: 2 additions & 2 deletions tests/components/whois/snapshots/test_config_flow.ambr
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# serializer version: 1
# name: test_full_flow_with_error[FailedParsingWhoisOutput-unexpected_response]
# name: test_full_flow_with_error[FailedParsingWhoisOutput-whois_command_failed]
FlowResultSnapshot({
'context': dict({
'source': 'user',
Expand Down Expand Up @@ -37,7 +37,7 @@
'version': 1,
})
# ---
# name: test_full_flow_with_error[UnknownDateFormat-unknown_date_format]
# name: test_full_flow_with_error[UnknownDateFormat-whois_command_failed]
FlowResultSnapshot({
'context': dict({
'source': 'user',
Expand Down
4 changes: 2 additions & 2 deletions tests/components/whois/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ async def test_full_user_flow(
("throw", "reason"),
[
(UnknownTld, "unknown_tld"),
(FailedParsingWhoisOutput, "unexpected_response"),
(UnknownDateFormat, "unknown_date_format"),
(FailedParsingWhoisOutput, "whois_command_failed"),
(UnknownDateFormat, "whois_command_failed"),
(WhoisCommandFailed, "whois_command_failed"),
],
)
Expand Down
2 changes: 2 additions & 0 deletions tests/components/whois/test_diagnostics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the diagnostics data provided by the Whois integration."""

import pytest
from syrupy.assertion import SnapshotAssertion

from homeassistant.core import HomeAssistant
Expand All @@ -9,6 +10,7 @@
from tests.typing import ClientSessionGenerator


@pytest.mark.usefixtures("mock_whois")
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
Expand Down
9 changes: 2 additions & 7 deletions tests/components/whois/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,9 @@
from unittest.mock import MagicMock

import pytest
from whois.exceptions import (
FailedParsingWhoisOutput,
UnknownDateFormat,
UnknownTld,
WhoisCommandFailed,
)

from homeassistant.components.whois.const import DOMAIN
from homeassistant.components.whois.helper import GenericWhoisError, WhoisUnknownTLD
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant

Expand Down Expand Up @@ -39,7 +34,7 @@ async def test_load_unload_config_entry(

@pytest.mark.parametrize(
"side_effect",
[FailedParsingWhoisOutput, UnknownDateFormat, UnknownTld, WhoisCommandFailed],
[WhoisUnknownTLD, GenericWhoisError],
)
async def test_error_handling(
hass: HomeAssistant,
Expand Down
20 changes: 11 additions & 9 deletions tests/components/whois/test_sensor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Tests for the sensors provided by the Whois integration."""

from unittest.mock import MagicMock

import pytest
from syrupy.assertion import SnapshotAssertion

Expand All @@ -14,12 +12,13 @@
from tests.common import async_fire_time_changed

pytestmark = [
pytest.mark.usefixtures("init_integration"),
pytest.mark.freeze_time("2022-01-01 12:00:00", tz_offset=0),
]


@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.usefixtures(
"entity_registry_enabled_by_default", "mock_whois", "init_integration"
)
@pytest.mark.parametrize(
"entity_id",
[
Expand Down Expand Up @@ -54,6 +53,7 @@ async def test_whois_sensors(
assert device_entry == snapshot


@pytest.mark.usefixtures("mock_whois_missing_some_attrs", "init_integration")
async def test_whois_sensors_missing_some_attrs(
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
) -> None:
Expand All @@ -65,6 +65,7 @@ async def test_whois_sensors_missing_some_attrs(
assert entry == snapshot


@pytest.mark.usefixtures("mock_whois", "init_integration")
@pytest.mark.parametrize(
"entity_id",
[
Expand All @@ -85,7 +86,11 @@ async def test_disabled_by_default_sensors(
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION


@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.usefixtures(
"entity_registry_enabled_by_default",
"mock_whois_empty",
"init_integration",
)
@pytest.mark.parametrize(
"entity_id",
[
Expand All @@ -100,11 +105,8 @@ async def test_disabled_by_default_sensors(
"sensor.home_assistant_io_reseller",
],
)
async def test_no_data(
hass: HomeAssistant, mock_whois: MagicMock, entity_id: str
) -> None:
async def test_no_data(hass: HomeAssistant, entity_id: str) -> None:
"""Test whois sensors become unknown when there is no data provided."""
mock_whois.return_value = None

async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL)
await hass.async_block_till_done()
Expand Down