Azure DevOps build sensor attributes to new sensors (#114948)

* Setup for split

* Adjust to allow for None

* Create

* Add missing

* Fix datetime parsing in Azure DevOps sensor

* Remove definition id and name

These aren't needed and will never change

* Add tests for each sensor

* Add tests for edge cases

* Rename translations

* Update

* Use base sensor descriptions

* Remove

* Drop status

using this later for an event entity

* Switch to timestamp

* Switch to timestamp

* Merge

* Update snapshot

* Improvements from @joostlek

* Update homeassistant/components/azure_devops/sensor.py

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Aidan Timson 2024-06-04 09:50:43 +01:00 committed by GitHub
parent 7815840194
commit 42414d55e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1581 additions and 94 deletions

View File

@ -2,14 +2,12 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Final from typing import Final
from aioazuredevops.builds import DevOpsBuild from aioazuredevops.builds import DevOpsBuild
from aioazuredevops.client import DevOpsClient from aioazuredevops.client import DevOpsClient
from aioazuredevops.core import DevOpsProject
import aiohttp import aiohttp
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -18,7 +16,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
DataUpdateCoordinator, DataUpdateCoordinator,
@ -34,14 +31,6 @@ PLATFORMS = [Platform.SENSOR]
BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1"
@dataclass(frozen=True)
class AzureDevOpsEntityDescription(EntityDescription):
"""Class describing Azure DevOps entities."""
organization: str = ""
project: DevOpsProject = None
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) aiohttp_session = async_get_clientsession(hass)
@ -108,32 +97,17 @@ class AzureDevOpsEntity(CoordinatorEntity[DataUpdateCoordinator[list[DevOpsBuild
_attr_has_entity_name = True _attr_has_entity_name = True
entity_description: AzureDevOpsEntityDescription
def __init__( def __init__(
self, self,
coordinator: DataUpdateCoordinator[list[DevOpsBuild]], coordinator: DataUpdateCoordinator[list[DevOpsBuild]],
entity_description: AzureDevOpsEntityDescription, organization: str,
project_name: str,
) -> None: ) -> None:
"""Initialize the Azure DevOps entity.""" """Initialize the Azure DevOps entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = entity_description self._attr_device_info = DeviceInfo(
self._attr_unique_id: str = (
f"{entity_description.organization}_{entity_description.key}"
)
self._organization: str = entity_description.organization
self._project_name: str = entity_description.project.name
class AzureDevOpsDeviceEntity(AzureDevOpsEntity):
"""Defines a Azure DevOps device entity."""
@property
def device_info(self) -> DeviceInfo:
"""Return device information about this Azure DevOps instance."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, self._organization, self._project_name)}, # type: ignore[arg-type] identifiers={(DOMAIN, organization, project_name)}, # type: ignore[arg-type]
manufacturer=self._organization, manufacturer=organization,
name=self._project_name, name=project_name,
) )

View File

@ -2,89 +2,186 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
import logging
from typing import Any from typing import Any
from aioazuredevops.builds import DevOpsBuild from aioazuredevops.builds import DevOpsBuild
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry 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 . import AzureDevOpsDeviceEntity, AzureDevOpsEntityDescription from . import AzureDevOpsEntity
from .const import CONF_ORG, DOMAIN from .const import CONF_ORG, DOMAIN
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class AzureDevOpsSensorEntityDescription( class AzureDevOpsBuildSensorEntityDescription(SensorEntityDescription):
AzureDevOpsEntityDescription, SensorEntityDescription """Class describing Azure DevOps base build sensor entities."""
):
"""Class describing Azure DevOps sensor entities."""
build_key: int attr_fn: Callable[[DevOpsBuild], dict[str, Any] | None] = lambda _: None
attrs: Callable[[DevOpsBuild], Any] value_fn: Callable[[DevOpsBuild], datetime | StateType]
value: Callable[[DevOpsBuild], StateType]
BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, ...] = (
# Attributes are deprecated in 2024.7 and can be removed in 2025.1
AzureDevOpsBuildSensorEntityDescription(
key="latest_build",
translation_key="latest_build",
attr_fn=lambda build: {
"definition_id": (build.definition.build_id if build.definition else None),
"definition_name": (build.definition.name if build.definition else None),
"id": build.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 if build.links else None,
"queue_time": build.queue_time,
"start_time": build.start_time,
"finish_time": build.finish_time,
},
value_fn=lambda build: build.build_number,
),
AzureDevOpsBuildSensorEntityDescription(
key="build_id",
translation_key="build_id",
entity_registry_visible_default=False,
value_fn=lambda build: build.build_id,
),
AzureDevOpsBuildSensorEntityDescription(
key="reason",
translation_key="reason",
entity_registry_visible_default=False,
value_fn=lambda build: build.reason,
),
AzureDevOpsBuildSensorEntityDescription(
key="result",
translation_key="result",
entity_registry_visible_default=False,
value_fn=lambda build: build.result,
),
AzureDevOpsBuildSensorEntityDescription(
key="source_branch",
translation_key="source_branch",
entity_registry_enabled_default=False,
entity_registry_visible_default=False,
value_fn=lambda build: build.source_branch,
),
AzureDevOpsBuildSensorEntityDescription(
key="source_version",
translation_key="source_version",
entity_registry_visible_default=False,
value_fn=lambda build: build.source_version,
),
AzureDevOpsBuildSensorEntityDescription(
key="queue_time",
translation_key="queue_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_registry_enabled_default=False,
entity_registry_visible_default=False,
value_fn=lambda build: parse_datetime(build.queue_time),
),
AzureDevOpsBuildSensorEntityDescription(
key="start_time",
translation_key="start_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_registry_visible_default=False,
value_fn=lambda build: parse_datetime(build.start_time),
),
AzureDevOpsBuildSensorEntityDescription(
key="finish_time",
translation_key="finish_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_registry_visible_default=False,
value_fn=lambda build: parse_datetime(build.finish_time),
),
AzureDevOpsBuildSensorEntityDescription(
key="url",
translation_key="url",
value_fn=lambda build: build.links.web if build.links else None,
),
)
def parse_datetime(value: str | None) -> datetime | None:
"""Parse datetime string."""
if value is None:
return None
return dt_util.parse_datetime(value)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: ConfigEntry,
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, project = hass.data[DOMAIN][entry.entry_id]
initial_builds: list[DevOpsBuild] = coordinator.data
sensors = [ async_add_entities(
AzureDevOpsSensor( AzureDevOpsBuildSensor(
coordinator, coordinator,
AzureDevOpsSensorEntityDescription( description,
key=f"{build.project.project_id}_{build.definition.build_id}_latest_build", entry.data[CONF_ORG],
translation_key="latest_build", project.name,
translation_placeholders={"definition_name": build.definition.name}, key,
attrs=lambda build: {
"definition_id": (
build.definition.build_id if build.definition else None
),
"definition_name": (
build.definition.name if build.definition else None
),
"id": build.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 if build.links else None,
"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) for description in BASE_BUILD_SENSOR_DESCRIPTIONS
] for key, build in enumerate(initial_builds)
if build.project and build.definition
async_add_entities(sensors, True) )
class AzureDevOpsSensor(AzureDevOpsDeviceEntity, SensorEntity): class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity):
"""Define a Azure DevOps sensor.""" """Define a Azure DevOps build sensor."""
entity_description: AzureDevOpsSensorEntityDescription entity_description: AzureDevOpsBuildSensorEntityDescription
def __init__(
self,
coordinator: DataUpdateCoordinator[list[DevOpsBuild]],
description: AzureDevOpsBuildSensorEntityDescription,
organization: str,
project_name: str,
item_key: int,
) -> None:
"""Initialize."""
super().__init__(coordinator, organization, project_name)
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_translation_placeholders = {
"definition_name": self.build.definition.name
}
@property @property
def native_value(self) -> StateType: def build(self) -> DevOpsBuild:
"""Return the build."""
return self.coordinator.data[self.item_key]
@property
def native_value(self) -> datetime | StateType:
"""Return the state.""" """Return the state."""
build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] return self.entity_description.value_fn(self.build)
return self.entity_description.value(build)
@property @property
def extra_state_attributes(self) -> dict[str, Any]: def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes of the entity.""" """Return the state attributes of the entity."""
build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] return self.entity_description.attr_fn(self.build)
return self.entity_description.attrs(build)

View File

@ -31,8 +31,35 @@
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"build_id": {
"name": "{definition_name} latest build id"
},
"finish_time": {
"name": "{definition_name} latest build finish time"
},
"latest_build": { "latest_build": {
"name": "{definition_name} latest build" "name": "{definition_name} latest build"
},
"queue_time": {
"name": "{definition_name} latest build queue time"
},
"reason": {
"name": "{definition_name} latest build reason"
},
"result": {
"name": "{definition_name} latest build result"
},
"source_branch": {
"name": "{definition_name} latest build source branch"
},
"source_version": {
"name": "{definition_name} latest build source version"
},
"start_time": {
"name": "{definition_name} latest build start time"
},
"url": {
"name": "{definition_name} latest build url"
} }
} }
} }

View File

@ -43,7 +43,7 @@ DEVOPS_PROJECT = DevOpsProject(
DEVOPS_BUILD_DEFINITION = DevOpsBuildDefinition( DEVOPS_BUILD_DEFINITION = DevOpsBuildDefinition(
build_id=9876, build_id=9876,
name="Test Build", name="CI",
url=f"https://dev.azure.com/{ORGANIZATION}/{PROJECT}/_apis/build/definitions/1", url=f"https://dev.azure.com/{ORGANIZATION}/{PROJECT}/_apis/build/definitions/1",
path="", path="",
build_type="build", build_type="build",
@ -68,6 +68,16 @@ DEVOPS_BUILD = DevOpsBuild(
links=None, links=None,
) )
DEVOPS_BUILD_MISSING_DATA = DevOpsBuild(
build_id=6789,
definition=DEVOPS_BUILD_DEFINITION,
project=DEVOPS_PROJECT,
)
DEVOPS_BUILD_MISSING_PROJECT_DEFINITION = DevOpsBuild(
build_id=9876,
)
async def setup_integration( async def setup_integration(
hass: HomeAssistant, hass: HomeAssistant,

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ async def test_load_unload_entry(
assert mock_devops_client.authorized assert mock_devops_client.authorized
assert mock_devops_client.authorize.call_count == 1 assert mock_devops_client.authorize.call_count == 1
assert mock_devops_client.get_builds.call_count == 2 assert mock_devops_client.get_builds.call_count == 1
assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.state is ConfigEntryState.LOADED

View File

@ -8,10 +8,28 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from . import setup_integration from . import (
DEVOPS_BUILD_MISSING_DATA,
DEVOPS_BUILD_MISSING_PROJECT_DEFINITION,
setup_integration,
)
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
BASE_ENTITY_ID = "sensor.testproject_ci"
SENSOR_KEYS = [
"latest_build",
"latest_build_id",
"latest_build_reason",
"latest_build_result",
"latest_build_source_branch",
"latest_build_source_version",
"latest_build_queue_time",
"latest_build_start_time",
"latest_build_finish_time",
"latest_build_url",
]
@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors( async def test_sensors(
@ -21,13 +39,53 @@ async def test_sensors(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
mock_devops_client: AsyncMock, mock_devops_client: AsyncMock,
) -> None: ) -> None:
"""Test the sensor entities.""" """Test sensor entities."""
assert await setup_integration(hass, mock_config_entry) assert await setup_integration(hass, mock_config_entry)
assert ( for sensor_key in SENSOR_KEYS:
entry := entity_registry.async_get("sensor.testproject_test_build_latest_build") assert (entry := entity_registry.async_get(f"{BASE_ENTITY_ID}_{sensor_key}"))
)
assert entry == snapshot(name=f"{entry.entity_id}-entry") assert entry == snapshot(name=f"{entry.entity_id}-entry")
assert hass.states.get(entry.entity_id) == snapshot(name=f"{entry.entity_id}-state") assert hass.states.get(entry.entity_id) == snapshot(
name=f"{entry.entity_id}-state"
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors_missing_data(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_devops_client: AsyncMock,
) -> None:
"""Test sensor entities with missing data."""
mock_devops_client.get_builds.return_value = [DEVOPS_BUILD_MISSING_DATA]
assert await setup_integration(hass, mock_config_entry)
for sensor_key in SENSOR_KEYS:
assert (entry := entity_registry.async_get(f"{BASE_ENTITY_ID}_{sensor_key}"))
assert hass.states.get(entry.entity_id) == snapshot(
name=f"{entry.entity_id}-state-missing-data"
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors_missing_project_definition(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_devops_client: AsyncMock,
) -> None:
"""Test sensor entities with missing project and definition."""
mock_devops_client.get_builds.return_value = [
DEVOPS_BUILD_MISSING_PROJECT_DEFINITION
]
assert await setup_integration(hass, mock_config_entry)
for sensor_key in SENSOR_KEYS:
assert not entity_registry.async_get(f"{BASE_ENTITY_ID}_{sensor_key}")