mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Restructure and setup dedicated coordinator for Azure DevOps (#119199)
This commit is contained in:
parent
a0abd537c6
commit
c907912dd1
@ -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,
|
||||
)
|
||||
|
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.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:
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user