"""Persistently store issues raised by integrations.""" from __future__ import annotations import dataclasses from datetime import datetime from enum import StrEnum import functools as ft from typing import Any, Literal, TypedDict, cast from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant, callback from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey from .registry import BaseRegistry from .storage import Store DATA_REGISTRY: HassKey[IssueRegistry] = HassKey("issue_registry") EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED: EventType[EventIssueRegistryUpdatedData] = ( EventType("repairs_issue_registry_updated") ) STORAGE_KEY = "repairs.issue_registry" STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MINOR = 2 class EventIssueRegistryUpdatedData(TypedDict): """Event data for when the issue registry is updated.""" action: Literal["create", "remove", "update"] domain: str issue_id: str class IssueSeverity(StrEnum): """Issue severity.""" CRITICAL = "critical" ERROR = "error" WARNING = "warning" @dataclasses.dataclass(slots=True, frozen=True) class IssueEntry: """Issue Registry Entry.""" active: bool breaks_in_ha_version: str | None created: datetime data: dict[str, str | int | float | None] | None dismissed_version: str | None domain: str is_fixable: bool | None is_persistent: bool # Used if an integration creates issues for other integrations (ie alerts) issue_domain: str | None issue_id: str learn_more_url: str | None severity: IssueSeverity | None translation_key: str | None translation_placeholders: dict[str, str] | None def to_json(self) -> dict[str, Any]: """Return a JSON serializable representation for storage.""" result = { "created": self.created.isoformat(), "dismissed_version": self.dismissed_version, "domain": self.domain, "is_persistent": False, "issue_id": self.issue_id, } if not self.is_persistent: return result return { **result, "breaks_in_ha_version": self.breaks_in_ha_version, "data": self.data, "is_fixable": self.is_fixable, "is_persistent": True, "issue_domain": self.issue_domain, "issue_id": self.issue_id, "learn_more_url": self.learn_more_url, "severity": self.severity, "translation_key": self.translation_key, "translation_placeholders": self.translation_placeholders, } class IssueRegistryStore(Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" async def _async_migrate_func( self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] ) -> dict[str, Any]: """Migrate to the new version.""" if old_major_version == 1 and old_minor_version < 2: # Version 1.2 adds is_persistent for issue in old_data["issues"]: issue["is_persistent"] = False return old_data class IssueRegistry(BaseRegistry): """Class to hold a registry of issues.""" def __init__(self, hass: HomeAssistant, *, read_only: bool = False) -> None: """Initialize the issue registry.""" self.hass = hass self.issues: dict[tuple[str, str], IssueEntry] = {} self._read_only = read_only self._store = IssueRegistryStore( hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, atomic_writes=True, minor_version=STORAGE_VERSION_MINOR, read_only=read_only, ) @callback def async_get_issue(self, domain: str, issue_id: str) -> IssueEntry | None: """Get issue by id.""" return self.issues.get((domain, issue_id)) @callback def async_get_or_create( self, domain: str, issue_id: str, *, breaks_in_ha_version: str | None = None, data: dict[str, str | int | float | None] | None = None, is_fixable: bool, is_persistent: bool, issue_domain: str | None = None, learn_more_url: str | None = None, severity: IssueSeverity, translation_key: str, translation_placeholders: dict[str, str] | None = None, ) -> IssueEntry: """Get issue. Create if it doesn't exist.""" self.hass.verify_event_loop_thread("async_get_or_create") if (issue := self.async_get_issue(domain, issue_id)) is None: issue = IssueEntry( active=True, breaks_in_ha_version=breaks_in_ha_version, created=dt_util.utcnow(), data=data, dismissed_version=None, domain=domain, is_fixable=is_fixable, is_persistent=is_persistent, issue_domain=issue_domain, issue_id=issue_id, learn_more_url=learn_more_url, severity=severity, translation_key=translation_key, translation_placeholders=translation_placeholders, ) self.issues[(domain, issue_id)] = issue self.async_schedule_save() self.hass.bus.async_fire_internal( EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, EventIssueRegistryUpdatedData( action="create", domain=domain, issue_id=issue_id, ), ) else: replacement = dataclasses.replace( issue, active=True, breaks_in_ha_version=breaks_in_ha_version, data=data, is_fixable=is_fixable, is_persistent=is_persistent, issue_domain=issue_domain, learn_more_url=learn_more_url, severity=severity, translation_key=translation_key, translation_placeholders=translation_placeholders, ) # Only fire is something changed if replacement != issue: issue = self.issues[(domain, issue_id)] = replacement self.async_schedule_save() self.hass.bus.async_fire_internal( EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, EventIssueRegistryUpdatedData( action="update", domain=domain, issue_id=issue_id, ), ) return issue @callback def async_delete(self, domain: str, issue_id: str) -> None: """Delete issue.""" self.hass.verify_event_loop_thread("async_delete") if self.issues.pop((domain, issue_id), None) is None: return self.async_schedule_save() self.hass.bus.async_fire_internal( EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, EventIssueRegistryUpdatedData( action="remove", domain=domain, issue_id=issue_id, ), ) @callback def async_ignore(self, domain: str, issue_id: str, ignore: bool) -> IssueEntry: """Ignore issue.""" self.hass.verify_event_loop_thread("async_ignore") old = self.issues[(domain, issue_id)] dismissed_version = ha_version if ignore else None if old.dismissed_version == dismissed_version: return old issue = self.issues[(domain, issue_id)] = dataclasses.replace( old, dismissed_version=dismissed_version, ) self.async_schedule_save() self.hass.bus.async_fire_internal( EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, EventIssueRegistryUpdatedData( action="update", domain=domain, issue_id=issue_id, ), ) return issue async def async_load(self) -> None: """Load the issue registry.""" data = await self._store.async_load() issues: dict[tuple[str, str], IssueEntry] = {} if isinstance(data, dict): for issue in data["issues"]: created = cast(datetime, dt_util.parse_datetime(issue["created"])) if issue["is_persistent"]: issues[(issue["domain"], issue["issue_id"])] = IssueEntry( active=True, breaks_in_ha_version=issue["breaks_in_ha_version"], created=created, data=issue["data"], dismissed_version=issue["dismissed_version"], domain=issue["domain"], is_fixable=issue["is_fixable"], is_persistent=issue["is_persistent"], issue_id=issue["issue_id"], issue_domain=issue["issue_domain"], learn_more_url=issue["learn_more_url"], severity=issue["severity"], translation_key=issue["translation_key"], translation_placeholders=issue["translation_placeholders"], ) else: issues[(issue["domain"], issue["issue_id"])] = IssueEntry( active=False, breaks_in_ha_version=None, created=created, data=None, dismissed_version=issue["dismissed_version"], domain=issue["domain"], is_fixable=None, is_persistent=issue["is_persistent"], issue_id=issue["issue_id"], issue_domain=None, learn_more_url=None, severity=None, translation_key=None, translation_placeholders=None, ) self.issues = issues @callback def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]: """Return data of issue registry to store in a file.""" data = {} data["issues"] = [entry.to_json() for entry in self.issues.values()] return data @callback def async_get(hass: HomeAssistant) -> IssueRegistry: """Get issue registry.""" return hass.data[DATA_REGISTRY] async def async_load(hass: HomeAssistant, *, read_only: bool = False) -> None: """Load issue registry.""" assert DATA_REGISTRY not in hass.data hass.data[DATA_REGISTRY] = IssueRegistry(hass, read_only=read_only) await hass.data[DATA_REGISTRY].async_load() @callback def async_create_issue( hass: HomeAssistant, domain: str, issue_id: str, *, breaks_in_ha_version: str | None = None, data: dict[str, str | int | float | None] | None = None, is_fixable: bool, is_persistent: bool = False, issue_domain: str | None = None, learn_more_url: str | None = None, severity: IssueSeverity, translation_key: str, translation_placeholders: dict[str, str] | None = None, ) -> None: """Create an issue, or replace an existing one.""" # Verify the breaks_in_ha_version is a valid version string if breaks_in_ha_version: AwesomeVersion( breaks_in_ha_version, ensure_strategy=AwesomeVersionStrategy.CALVER, ) issue_registry = async_get(hass) issue_registry.async_get_or_create( domain, issue_id, breaks_in_ha_version=breaks_in_ha_version, data=data, is_fixable=is_fixable, is_persistent=is_persistent, issue_domain=issue_domain, learn_more_url=learn_more_url, severity=severity, translation_key=translation_key, translation_placeholders=translation_placeholders, ) def create_issue( hass: HomeAssistant, domain: str, issue_id: str, *, breaks_in_ha_version: str | None = None, data: dict[str, str | int | float | None] | None = None, is_fixable: bool, is_persistent: bool = False, issue_domain: str | None = None, learn_more_url: str | None = None, severity: IssueSeverity, translation_key: str, translation_placeholders: dict[str, str] | None = None, ) -> None: """Create an issue, or replace an existing one.""" return run_callback_threadsafe( hass.loop, ft.partial( async_create_issue, hass, domain, issue_id, breaks_in_ha_version=breaks_in_ha_version, data=data, is_fixable=is_fixable, is_persistent=is_persistent, issue_domain=issue_domain, learn_more_url=learn_more_url, severity=severity, translation_key=translation_key, translation_placeholders=translation_placeholders, ), ).result() @callback def async_delete_issue(hass: HomeAssistant, domain: str, issue_id: str) -> None: """Delete an issue. It is not an error to delete an issue that does not exist. """ issue_registry = async_get(hass) issue_registry.async_delete(domain, issue_id) def delete_issue(hass: HomeAssistant, domain: str, issue_id: str) -> None: """Delete an issue. It is not an error to delete an issue that does not exist. """ return run_callback_threadsafe( hass.loop, async_delete_issue, hass, domain, issue_id ).result() @callback def async_ignore_issue( hass: HomeAssistant, domain: str, issue_id: str, ignore: bool ) -> None: """Ignore an issue. Will raise if the issue does not exist. """ issue_registry = async_get(hass) issue_registry.async_ignore(domain, issue_id, ignore)