core/homeassistant/helpers/issue_registry.py

431 lines
14 KiB
Python

"""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 .singleton import singleton
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) -> None:
"""Initialize the issue registry."""
self.hass = hass
self.issues: dict[tuple[str, str], IssueEntry] = {}
self._store = IssueRegistryStore(
hass,
STORAGE_VERSION_MAJOR,
STORAGE_KEY,
atomic_writes=True,
minor_version=STORAGE_VERSION_MINOR,
)
@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("issue_registry.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("issue_registry.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("issue_registry.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
@callback
def make_read_only(self) -> None:
"""Make the registry read-only.
This method is irreversible.
"""
self._store.make_read_only()
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
@singleton(DATA_REGISTRY)
def async_get(hass: HomeAssistant) -> IssueRegistry:
"""Get issue registry."""
return IssueRegistry(hass)
async def async_load(hass: HomeAssistant, *, read_only: bool = False) -> None:
"""Load issue registry."""
ir = async_get(hass)
if read_only: # only used in for check config script
ir.make_read_only()
return await ir.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)