diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index fe27ec8bcecd2e525400b71bf84279814513eafe..f97dfe730d049e36f40825644f7539c1a591a01f 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -1,43 +1,84 @@ """Support for Azure DevOps.""" from __future__ import annotations +from dataclasses import dataclass +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, ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) -from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DATA_AZURE_DEVOPS_CLIENT, DOMAIN +from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = ["sensor"] +BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + + +@dataclass +class AzureDevOpsEntityDescription(EntityDescription): + """Class describing Azure DevOps entities.""" + + organization: str = "" + project: DevOpsProject = None + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Azure DevOps from a config entry.""" client = DevOpsClient() - try: - if entry.data[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 may need to update your token" - ) - await client.get_project(entry.data[CONF_ORG], entry.data[CONF_PROJECT]) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - raise ConfigEntryNotReady from exception - - instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" - hass.data.setdefault(instance_key, {})[DATA_AZURE_DEVOPS_CLIENT] = client - - # Setup components + 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: + return await client.get_builds( + entry.data[CONF_ORG], + entry.data[CONF_PROJECT], + BUILDS_QUERY, + ) + except (aiohttp.ClientError, aiohttp.ClientError) as exception: + raise UpdateFailed from exception + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{DOMAIN}_coordinator", + update_method=async_update_data, + update_interval=timedelta(seconds=300), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator, project + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -45,36 +86,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Azure DevOps config entry.""" - del hass.data[f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}"] - - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + 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(Entity): +class AzureDevOpsEntity(CoordinatorEntity): """Defines a base Azure DevOps entity.""" - def __init__(self, organization: str, project: str, name: str, icon: str) -> None: + coordinator: DataUpdateCoordinator[list[DevOpsBuild]] + entity_description: AzureDevOpsEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[list[DevOpsBuild]], + entity_description: AzureDevOpsEntityDescription, + ) -> None: """Initialize the Azure DevOps entity.""" - self._attr_name = name - self._attr_icon = icon - self.organization = organization - self.project = project - - async def async_update(self) -> None: - """Update Azure DevOps entity.""" - if await self._azure_devops_update(): - self._attr_available = True - else: - if self._attr_available: - _LOGGER.debug( - "An error occurred while updating Azure DevOps sensor", - exc_info=True, - ) - self._attr_available = False - - async def _azure_devops_update(self) -> bool: - """Update Azure DevOps entity.""" - raise NotImplementedError() + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id: str = "_".join( + [entity_description.organization, entity_description.key] + ) + self._organization: str = entity_description.organization + self._project_name: str = entity_description.project.name class AzureDevOpsDeviceEntity(AzureDevOpsEntity): @@ -85,7 +121,7 @@ class AzureDevOpsDeviceEntity(AzureDevOpsEntity): """Return device information about this Azure DevOps instance.""" return DeviceInfo( entry_type="service", - identifiers={(DOMAIN, self.organization, self.project)}, # type: ignore - manufacturer=self.organization, - name=self.project, + identifiers={(DOMAIN, self._organization, self._project_name)}, # type: ignore + manufacturer=self._organization, + name=self._project_name, ) diff --git a/homeassistant/components/azure_devops/const.py b/homeassistant/components/azure_devops/const.py index 40610ba7baa57bc90e6e27928fb21cd1c01c7a90..adaf5ebe767555a15914180185cb07b3d6280ba8 100644 --- a/homeassistant/components/azure_devops/const.py +++ b/homeassistant/components/azure_devops/const.py @@ -1,11 +1,6 @@ """Constants for the Azure DevOps integration.""" DOMAIN = "azure_devops" -DATA_AZURE_DEVOPS_CLIENT = "azure_devops_client" -DATA_ORG = "organization" -DATA_PROJECT = "project" -DATA_PAT = "personal_access_token" - CONF_ORG = "organization" CONF_PROJECT = "project" CONF_PAT = "personal_access_token" diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index 67d472abc1e3ef3fc6835c795bb0cf774487d731..dc81cf531719ca2494ee718c4199d623f593651c 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -1,126 +1,94 @@ """Support for Azure DevOps sensors.""" from __future__ import annotations -from datetime import timedelta -import logging +from dataclasses import dataclass +from typing import Any, Callable from aioazuredevops.builds import DevOpsBuild -from aioazuredevops.client import DevOpsClient -import aiohttp - -from homeassistant.components.azure_devops import AzureDevOpsDeviceEntity -from homeassistant.components.azure_devops.const import ( - CONF_ORG, - CONF_PROJECT, - DATA_AZURE_DEVOPS_CLIENT, - DATA_ORG, - DATA_PROJECT, - DOMAIN, + +from homeassistant.components.azure_devops import ( + AzureDevOpsDeviceEntity, + AzureDevOpsEntityDescription, ) -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.azure_devops.const import CONF_ORG, DOMAIN +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + + +@dataclass +class AzureDevOpsSensorEntityDescriptionMixin: + """Mixin class for required Azure DevOps sensor description keys.""" -_LOGGER = logging.getLogger(__name__) + build_key: int -SCAN_INTERVAL = timedelta(seconds=300) -PARALLEL_UPDATES = 4 -BUILDS_QUERY = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" +@dataclass +class AzureDevOpsSensorEntityDescription( + AzureDevOpsEntityDescription, + SensorEntityDescription, + AzureDevOpsSensorEntityDescriptionMixin, +): + """Class describing Azure DevOps sensor entities.""" + + attrs: Callable[[DevOpsBuild], Any] = round + value: Callable[[DevOpsBuild], StateType] = round async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Azure DevOps sensor based on a config entry.""" - instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" - client = hass.data[instance_key][DATA_AZURE_DEVOPS_CLIENT] - organization = entry.data[DATA_ORG] - project = entry.data[DATA_PROJECT] - sensors = [] - - try: - builds: list[DevOpsBuild] = await client.get_builds( - organization, project, BUILDS_QUERY - ) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - raise PlatformNotReady from exception - - for build in builds: - sensors.append( - AzureDevOpsLatestBuildSensor(client, organization, project, build) + coordinator, project = hass.data[DOMAIN][entry.entry_id] + + sensors = [ + AzureDevOpsSensor( + coordinator, + AzureDevOpsSensorEntityDescription( + key=f"{build.project.id}_{build.definition.id}_latest_build", + name=f"{build.project.name} {build.definition.name} Latest Build", + icon="mdi:pipe", + attrs=lambda build: { + "definition_id": build.definition.id, + "definition_name": build.definition.name, + "id": build.id, + "reason": build.reason, + "result": build.result, + "source_branch": build.source_branch, + "source_version": build.source_version, + "status": build.status, + "url": build.links.web, + "queue_time": build.queue_time, + "start_time": build.start_time, + "finish_time": build.finish_time, + }, + build_key=key, + organization=entry.data[CONF_ORG], + project=project, + value=lambda build: build.build_number, + ), ) + for key, build in enumerate(coordinator.data) + ] async_add_entities(sensors, True) class AzureDevOpsSensor(AzureDevOpsDeviceEntity, SensorEntity): - """Defines a Azure DevOps sensor.""" - - def __init__( - self, - client: DevOpsClient, - organization: str, - project: str, - key: str, - name: str, - icon: str, - measurement: str = "", - unit_of_measurement: str = "", - ) -> None: - """Initialize Azure DevOps sensor.""" - self._attr_native_unit_of_measurement = unit_of_measurement - self.client = client - self.organization = organization - self.project = project - self._attr_unique_id = "_".join([organization, key]) - - super().__init__(organization, project, name, icon) - - -class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): - """Defines a Azure DevOps card count sensor.""" - - def __init__( - self, client: DevOpsClient, organization: str, project: str, build: DevOpsBuild - ) -> None: - """Initialize Azure DevOps sensor.""" - self.build: DevOpsBuild = build - super().__init__( - client, - organization, - project, - f"{build.project.id}_{build.definition.id}_latest_build", - f"{build.project.name} {build.definition.name} Latest Build", - "mdi:pipe", - ) + """Define a Azure DevOps sensor.""" + + entity_description: AzureDevOpsSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state.""" + build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] + return self.entity_description.value(build) - async def _azure_devops_update(self) -> bool: - """Update Azure DevOps entity.""" - try: - build: DevOpsBuild = await self.client.get_build( - self.organization, self.project, self.build.id - ) - except aiohttp.ClientError as exception: - _LOGGER.warning(exception) - self._attr_available = False - return False - self._attr_native_value = build.build_number - self._attr_extra_state_attributes = { - "definition_id": build.definition.id, - "definition_name": build.definition.name, - "id": build.id, - "reason": build.reason, - "result": build.result, - "source_branch": build.source_branch, - "source_version": build.source_version, - "status": build.status, - "url": build.links.web, - "queue_time": build.queue_time, - "start_time": build.start_time, - "finish_time": build.finish_time, - } - self._attr_available = True - return True + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the entity.""" + build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] + return self.entity_description.attrs(build)