Skip to content
Snippets Groups Projects
Unverified Commit c907912d authored by Aidan Timson's avatar Aidan Timson Committed by GitHub
Browse files

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

parent a0abd537
No related branches found
No related tags found
No related merge requests found
......@@ -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
"""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,
)
"""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]
"""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.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:
......
"""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
......@@ -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,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment