Restructure and setup dedicated coordinator for Azure DevOps (#119199)
This commit is contained in:
parent
a0abd537c6
commit
c907912dd1
7 changed files with 208 additions and 95 deletions
|
@ -2,83 +2,45 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
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.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS = [Platform.SENSOR]
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1"
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Azure DevOps from a config entry."""
|
"""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:
|
# Create the data update coordinator
|
||||||
await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG])
|
coordinator = AzureDevOpsDataUpdateCoordinator(
|
||||||
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(
|
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
name=f"{DOMAIN}_coordinator",
|
entry=entry,
|
||||||
update_method=async_update_data,
|
|
||||||
update_interval=timedelta(seconds=300),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})
|
# Set up platforms
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator, project
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
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)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
del hass.data[DOMAIN][entry.entry_id]
|
del hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
return unload_ok
|
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,
|
|
||||||
)
|
|
||||||
|
|
116
homeassistant/components/azure_devops/coordinator.py
Normal file
116
homeassistant/components/azure_devops/coordinator.py
Normal 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,
|
||||||
|
)
|
15
homeassistant/components/azure_devops/data.py
Normal file
15
homeassistant/components/azure_devops/data.py
Normal 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]
|
28
homeassistant/components/azure_devops/entity.py
Normal file
28
homeassistant/components/azure_devops/entity.py
Normal 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,
|
||||||
|
)
|
|
@ -19,11 +19,11 @@ from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import AzureDevOpsEntity
|
from .const import DOMAIN
|
||||||
from .const import CONF_ORG, DOMAIN
|
from .coordinator import AzureDevOpsDataUpdateCoordinator
|
||||||
|
from .entity import AzureDevOpsEntity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -132,15 +132,13 @@ async def async_setup_entry(
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Azure DevOps sensor based on a config entry."""
|
"""Set up Azure DevOps sensor based on a config entry."""
|
||||||
coordinator, project = hass.data[DOMAIN][entry.entry_id]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
initial_builds: list[DevOpsBuild] = coordinator.data
|
initial_builds: list[DevOpsBuild] = coordinator.data.builds
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
AzureDevOpsBuildSensor(
|
AzureDevOpsBuildSensor(
|
||||||
coordinator,
|
coordinator,
|
||||||
description,
|
description,
|
||||||
entry.data[CONF_ORG],
|
|
||||||
project.name,
|
|
||||||
key,
|
key,
|
||||||
)
|
)
|
||||||
for description in BASE_BUILD_SENSOR_DESCRIPTIONS
|
for description in BASE_BUILD_SENSOR_DESCRIPTIONS
|
||||||
|
@ -156,17 +154,15 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: DataUpdateCoordinator[list[DevOpsBuild]],
|
coordinator: AzureDevOpsDataUpdateCoordinator,
|
||||||
description: AzureDevOpsBuildSensorEntityDescription,
|
description: AzureDevOpsBuildSensorEntityDescription,
|
||||||
organization: str,
|
|
||||||
project_name: str,
|
|
||||||
item_key: int,
|
item_key: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
super().__init__(coordinator, organization, project_name)
|
super().__init__(coordinator)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
self.item_key = item_key
|
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 = {
|
self._attr_translation_placeholders = {
|
||||||
"definition_name": self.build.definition.name
|
"definition_name": self.build.definition.name
|
||||||
}
|
}
|
||||||
|
@ -174,7 +170,7 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
|
||||||
@property
|
@property
|
||||||
def build(self) -> DevOpsBuild:
|
def build(self) -> DevOpsBuild:
|
||||||
"""Return the build."""
|
"""Return the build."""
|
||||||
return self.coordinator.data[self.item_key]
|
return self.coordinator.data.builds[self.item_key]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> datetime | StateType:
|
def native_value(self) -> datetime | StateType:
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
"""Test fixtures for Azure DevOps."""
|
"""Test fixtures for Azure DevOps."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator, Generator
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from typing_extensions import AsyncGenerator, Generator
|
|
||||||
|
|
||||||
from homeassistant.components.azure_devops.const import DOMAIN
|
from homeassistant.components.azure_devops.const import DOMAIN
|
||||||
|
|
||||||
|
@ -18,7 +18,8 @@ async def mock_devops_client() -> AsyncGenerator[MagicMock]:
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.azure_devops.DevOpsClient", autospec=True
|
"homeassistant.components.azure_devops.coordinator.DevOpsClient",
|
||||||
|
autospec=True,
|
||||||
) as mock_client,
|
) as mock_client,
|
||||||
patch(
|
patch(
|
||||||
"homeassistant.components.azure_devops.config_flow.DevOpsClient",
|
"homeassistant.components.azure_devops.config_flow.DevOpsClient",
|
||||||
|
@ -54,5 +55,5 @@ def mock_setup_entry() -> Generator[AsyncMock]:
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.azure_devops.async_setup_entry",
|
"homeassistant.components.azure_devops.async_setup_entry",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
) as mock_setup_entry:
|
) as mock_entry:
|
||||||
yield mock_setup_entry
|
yield mock_entry
|
||||||
|
|
|
@ -48,7 +48,22 @@ async def test_auth_failed(
|
||||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
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,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_devops_client: MagicMock,
|
mock_devops_client: MagicMock,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue