Restructure and setup dedicated coordinator for Azure DevOps (#119199)

This commit is contained in:
Aidan Timson 2024-06-11 17:08:58 +01:00 committed by GitHub
parent a0abd537c6
commit c907912dd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 208 additions and 95 deletions

View File

@ -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],
)
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
if builds is None:
raise UpdateFailed("No builds found")
return builds
coordinator = DataUpdateCoordinator(
# Create the data update coordinator
coordinator = AzureDevOpsDataUpdateCoordinator(
hass,
_LOGGER,
name=f"{DOMAIN}_coordinator",
update_method=async_update_data,
update_interval=timedelta(seconds=300),
entry=entry,
)
# Store the coordinator in hass data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
# 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])
# 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,
)

View File

@ -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,
)

View File

@ -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]

View File

@ -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,
)

View File

@ -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:

View File

@ -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

View File

@ -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,