From c907912dd1f112c792db1491c08ef624bd24eb38 Mon Sep 17 00:00:00 2001 From: Aidan Timson <contact@timmo.xyz> Date: Tue, 11 Jun 2024 17:08:58 +0100 Subject: [PATCH] Restructure and setup dedicated coordinator for Azure DevOps (#119199) --- .../components/azure_devops/__init__.py | 94 +++----------- .../components/azure_devops/coordinator.py | 116 ++++++++++++++++++ homeassistant/components/azure_devops/data.py | 15 +++ .../components/azure_devops/entity.py | 28 +++++ .../components/azure_devops/sensor.py | 22 ++-- tests/components/azure_devops/conftest.py | 9 +- tests/components/azure_devops/test_init.py | 17 ++- 7 files changed, 207 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/azure_devops/coordinator.py create mode 100644 homeassistant/components/azure_devops/data.py create mode 100644 homeassistant/components/azure_devops/entity.py diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index 27f7f790637..a6e531879b7 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -2,83 +2,45 @@ from __future__ import annotations -from datetime import timedelta import logging -from typing import Final - -from aioazuredevops.builds import DevOpsBuild -from aioazuredevops.client import DevOpsClient -import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN +from .const import CONF_PAT, CONF_PROJECT, DOMAIN +from .coordinator import AzureDevOpsDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Azure DevOps from a config entry.""" - aiohttp_session = async_get_clientsession(hass) - client = DevOpsClient(session=aiohttp_session) - if entry.data.get(CONF_PAT) is not None: - await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) - if not client.authorized: - raise ConfigEntryAuthFailed( - "Could not authorize with Azure DevOps. You will need to update your" - " token" - ) - - project = await client.get_project( - entry.data[CONF_ORG], - entry.data[CONF_PROJECT], + # Create the data update coordinator + coordinator = AzureDevOpsDataUpdateCoordinator( + hass, + _LOGGER, + entry=entry, ) - async def async_update_data() -> list[DevOpsBuild]: - """Fetch data from Azure DevOps.""" - - try: - builds = await client.get_builds( - entry.data[CONF_ORG], - entry.data[CONF_PROJECT], - BUILDS_QUERY, - ) - except aiohttp.ClientError as exception: - raise UpdateFailed from exception + # Store the coordinator in hass data + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator - if builds is None: - raise UpdateFailed("No builds found") + # If a personal access token is set, authorize the client + if entry.data.get(CONF_PAT) is not None: + await coordinator.authorize(entry.data[CONF_PAT]) - return builds - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{DOMAIN}_coordinator", - update_method=async_update_data, - update_interval=timedelta(seconds=300), - ) + # Set the project for the coordinator + coordinator.project = await coordinator.get_project(entry.data[CONF_PROJECT]) + # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator, project - + # Set up platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -89,25 +51,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: del hass.data[DOMAIN][entry.entry_id] - return unload_ok - -class AzureDevOpsEntity(CoordinatorEntity[DataUpdateCoordinator[list[DevOpsBuild]]]): - """Defines a base Azure DevOps entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[list[DevOpsBuild]], - organization: str, - project_name: str, - ) -> None: - """Initialize the Azure DevOps entity.""" - super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, organization, project_name)}, # type: ignore[arg-type] - manufacturer=organization, - name=project_name, - ) + return unload_ok diff --git a/homeassistant/components/azure_devops/coordinator.py b/homeassistant/components/azure_devops/coordinator.py new file mode 100644 index 00000000000..ba0528de282 --- /dev/null +++ b/homeassistant/components/azure_devops/coordinator.py @@ -0,0 +1,116 @@ +"""Define the Azure DevOps DataUpdateCoordinator.""" + +from collections.abc import Callable +from datetime import timedelta +import logging +from typing import Final + +from aioazuredevops.builds import DevOpsBuild +from aioazuredevops.client import DevOpsClient +from aioazuredevops.core import DevOpsProject +import aiohttp + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_ORG, DOMAIN +from .data import AzureDevOpsData + +BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + + +def ado_exception_none_handler(func: Callable) -> Callable: + """Handle exceptions or None to always return a value or raise.""" + + async def handler(*args, **kwargs): + try: + response = await func(*args, **kwargs) + except aiohttp.ClientError as exception: + raise UpdateFailed from exception + + if response is None: + raise UpdateFailed("No data returned from Azure DevOps") + + return response + + return handler + + +class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]): + """Class to manage and fetch Azure DevOps data.""" + + client: DevOpsClient + organization: str + project: DevOpsProject + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + entry: ConfigEntry, + ) -> None: + """Initialize global Azure DevOps data updater.""" + self.title = entry.title + + super().__init__( + hass=hass, + logger=logger, + name=DOMAIN, + update_interval=timedelta(seconds=300), + ) + + self.client = DevOpsClient(session=async_get_clientsession(hass)) + self.organization = entry.data[CONF_ORG] + + @ado_exception_none_handler + async def authorize( + self, + personal_access_token: str, + ) -> bool: + """Authorize with Azure DevOps.""" + await self.client.authorize( + personal_access_token, + self.organization, + ) + if not self.client.authorized: + raise ConfigEntryAuthFailed( + "Could not authorize with Azure DevOps. You will need to update your" + " token" + ) + + return True + + @ado_exception_none_handler + async def get_project( + self, + project: str, + ) -> DevOpsProject | None: + """Get the project.""" + return await self.client.get_project( + self.organization, + project, + ) + + @ado_exception_none_handler + async def _get_builds(self, project_name: str) -> list[DevOpsBuild] | None: + """Get the builds.""" + return await self.client.get_builds( + self.organization, + project_name, + BUILDS_QUERY, + ) + + async def _async_update_data(self) -> AzureDevOpsData: + """Fetch data from Azure DevOps.""" + # Get the builds from the project + builds = await self._get_builds(self.project.name) + + return AzureDevOpsData( + organization=self.organization, + project=self.project, + builds=builds, + ) diff --git a/homeassistant/components/azure_devops/data.py b/homeassistant/components/azure_devops/data.py new file mode 100644 index 00000000000..6cbd6eb3bc1 --- /dev/null +++ b/homeassistant/components/azure_devops/data.py @@ -0,0 +1,15 @@ +"""Data classes for Azure DevOps integration.""" + +from dataclasses import dataclass + +from aioazuredevops.builds import DevOpsBuild +from aioazuredevops.core import DevOpsProject + + +@dataclass(frozen=True, kw_only=True) +class AzureDevOpsData: + """Class describing Azure DevOps data.""" + + organization: str + project: DevOpsProject + builds: list[DevOpsBuild] diff --git a/homeassistant/components/azure_devops/entity.py b/homeassistant/components/azure_devops/entity.py new file mode 100644 index 00000000000..0a4a94d4b32 --- /dev/null +++ b/homeassistant/components/azure_devops/entity.py @@ -0,0 +1,28 @@ +"""Base entity for Azure DevOps.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AzureDevOpsDataUpdateCoordinator + + +class AzureDevOpsEntity(CoordinatorEntity[AzureDevOpsDataUpdateCoordinator]): + """Defines a base Azure DevOps entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AzureDevOpsDataUpdateCoordinator, + ) -> None: + """Initialize the Azure DevOps entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + (DOMAIN, coordinator.data.organization, coordinator.data.project.name) # type: ignore[arg-type] + }, + manufacturer=coordinator.data.organization, + name=coordinator.data.project.name, + ) diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index b1d975f0a70..7b2a1a15adf 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -19,11 +19,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import AzureDevOpsEntity -from .const import CONF_ORG, DOMAIN +from .const import DOMAIN +from .coordinator import AzureDevOpsDataUpdateCoordinator +from .entity import AzureDevOpsEntity _LOGGER = logging.getLogger(__name__) @@ -132,15 +132,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Azure DevOps sensor based on a config entry.""" - coordinator, project = hass.data[DOMAIN][entry.entry_id] - initial_builds: list[DevOpsBuild] = coordinator.data + coordinator = hass.data[DOMAIN][entry.entry_id] + initial_builds: list[DevOpsBuild] = coordinator.data.builds async_add_entities( AzureDevOpsBuildSensor( coordinator, description, - entry.data[CONF_ORG], - project.name, key, ) for description in BASE_BUILD_SENSOR_DESCRIPTIONS @@ -156,17 +154,15 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[list[DevOpsBuild]], + coordinator: AzureDevOpsDataUpdateCoordinator, description: AzureDevOpsBuildSensorEntityDescription, - organization: str, - project_name: str, item_key: int, ) -> None: """Initialize.""" - super().__init__(coordinator, organization, project_name) + super().__init__(coordinator) self.entity_description = description self.item_key = item_key - self._attr_unique_id = f"{organization}_{self.build.project.project_id}_{self.build.definition.build_id}_{description.key}" + self._attr_unique_id = f"{self.coordinator.data.organization}_{self.build.project.project_id}_{self.build.definition.build_id}_{description.key}" self._attr_translation_placeholders = { "definition_name": self.build.definition.name } @@ -174,7 +170,7 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): @property def build(self) -> DevOpsBuild: """Return the build.""" - return self.coordinator.data[self.item_key] + return self.coordinator.data.builds[self.item_key] @property def native_value(self) -> datetime | StateType: diff --git a/tests/components/azure_devops/conftest.py b/tests/components/azure_devops/conftest.py index 29569da2c90..97e113bbb39 100644 --- a/tests/components/azure_devops/conftest.py +++ b/tests/components/azure_devops/conftest.py @@ -1,9 +1,9 @@ """Test fixtures for Azure DevOps.""" +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest -from typing_extensions import AsyncGenerator, Generator from homeassistant.components.azure_devops.const import DOMAIN @@ -18,7 +18,8 @@ async def mock_devops_client() -> AsyncGenerator[MagicMock]: with ( patch( - "homeassistant.components.azure_devops.DevOpsClient", autospec=True + "homeassistant.components.azure_devops.coordinator.DevOpsClient", + autospec=True, ) as mock_client, patch( "homeassistant.components.azure_devops.config_flow.DevOpsClient", @@ -54,5 +55,5 @@ def mock_setup_entry() -> Generator[AsyncMock]: with patch( "homeassistant.components.azure_devops.async_setup_entry", return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry + ) as mock_entry: + yield mock_entry diff --git a/tests/components/azure_devops/test_init.py b/tests/components/azure_devops/test_init.py index 240edee82d7..a7655042f25 100644 --- a/tests/components/azure_devops/test_init.py +++ b/tests/components/azure_devops/test_init.py @@ -48,7 +48,22 @@ async def test_auth_failed( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR -async def test_update_failed( +async def test_update_failed_project( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_devops_client: MagicMock, +) -> None: + """Test a failed update entry.""" + mock_devops_client.get_project.side_effect = aiohttp.ClientError + + await setup_integration(hass, mock_config_entry) + + assert mock_devops_client.get_project.call_count == 1 + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_update_failed_builds( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_devops_client: MagicMock, -- GitLab