Home Assistant Unofficial Reference 2024.12.1
issue_registry.py
Go to the documentation of this file.
1 """Persistently store issues raised by integrations."""
2 
3 from __future__ import annotations
4 
5 import dataclasses
6 from datetime import datetime
7 from enum import StrEnum
8 import functools as ft
9 from typing import Any, Literal, TypedDict, cast
10 
11 from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
12 
13 from homeassistant.const import __version__ as ha_version
14 from homeassistant.core import HomeAssistant, callback
15 from homeassistant.util.async_ import run_callback_threadsafe
16 import homeassistant.util.dt as dt_util
17 from homeassistant.util.event_type import EventType
18 from homeassistant.util.hass_dict import HassKey
19 
20 from .registry import BaseRegistry
21 from .singleton import singleton
22 from .storage import Store
23 
24 DATA_REGISTRY: HassKey[IssueRegistry] = HassKey("issue_registry")
25 EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED: EventType[EventIssueRegistryUpdatedData] = (
26  EventType("repairs_issue_registry_updated")
27 )
28 STORAGE_KEY = "repairs.issue_registry"
29 STORAGE_VERSION_MAJOR = 1
30 STORAGE_VERSION_MINOR = 2
31 
32 
34  """Event data for when the issue registry is updated."""
35 
36  action: Literal["create", "remove", "update"]
37  domain: str
38  issue_id: str
39 
40 
41 class IssueSeverity(StrEnum):
42  """Issue severity."""
43 
44  CRITICAL = "critical"
45  ERROR = "error"
46  WARNING = "warning"
47 
48 
49 @dataclasses.dataclass(slots=True, frozen=True)
50 class IssueEntry:
51  """Issue Registry Entry."""
52 
53  active: bool
54  breaks_in_ha_version: str | None
55  created: datetime
56  data: dict[str, str | int | float | None] | None
57  dismissed_version: str | None
58  domain: str
59  is_fixable: bool | None
60  is_persistent: bool
61  # Used if an integration creates issues for other integrations (ie alerts)
62  issue_domain: str | None
63  issue_id: str
64  learn_more_url: str | None
65  severity: IssueSeverity | None
66  translation_key: str | None
67  translation_placeholders: dict[str, str] | None
68 
69  def to_json(self) -> dict[str, Any]:
70  """Return a JSON serializable representation for storage."""
71  result = {
72  "created": self.created.isoformat(),
73  "dismissed_version": self.dismissed_version,
74  "domain": self.domain,
75  "is_persistent": False,
76  "issue_id": self.issue_id,
77  }
78  if not self.is_persistent:
79  return result
80  return {
81  **result,
82  "breaks_in_ha_version": self.breaks_in_ha_version,
83  "data": self.data,
84  "is_fixable": self.is_fixable,
85  "is_persistent": True,
86  "issue_domain": self.issue_domain,
87  "issue_id": self.issue_id,
88  "learn_more_url": self.learn_more_url,
89  "severity": self.severity,
90  "translation_key": self.translation_key,
91  "translation_placeholders": self.translation_placeholders,
92  }
93 
94 
95 class IssueRegistryStore(Store[dict[str, list[dict[str, Any]]]]):
96  """Store entity registry data."""
97 
99  self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any]
100  ) -> dict[str, Any]:
101  """Migrate to the new version."""
102  if old_major_version == 1 and old_minor_version < 2:
103  # Version 1.2 adds is_persistent
104  for issue in old_data["issues"]:
105  issue["is_persistent"] = False
106  return old_data
107 
108 
110  """Class to hold a registry of issues."""
111 
112  def __init__(self, hass: HomeAssistant) -> None:
113  """Initialize the issue registry."""
114  self.hasshass = hass
115  self.issuesissues: dict[tuple[str, str], IssueEntry] = {}
117  hass,
118  STORAGE_VERSION_MAJOR,
119  STORAGE_KEY,
120  atomic_writes=True,
121  minor_version=STORAGE_VERSION_MINOR,
122  )
123 
124  @callback
125  def async_get_issue(self, domain: str, issue_id: str) -> IssueEntry | None:
126  """Get issue by id."""
127  return self.issuesissues.get((domain, issue_id))
128 
129  @callback
131  self,
132  domain: str,
133  issue_id: str,
134  *,
135  breaks_in_ha_version: str | None = None,
136  data: dict[str, str | int | float | None] | None = None,
137  is_fixable: bool,
138  is_persistent: bool,
139  issue_domain: str | None = None,
140  learn_more_url: str | None = None,
141  severity: IssueSeverity,
142  translation_key: str,
143  translation_placeholders: dict[str, str] | None = None,
144  ) -> IssueEntry:
145  """Get issue. Create if it doesn't exist."""
146  self.hasshass.verify_event_loop_thread("issue_registry.async_get_or_create")
147  if (issue := self.async_get_issueasync_get_issue(domain, issue_id)) is None:
148  issue = IssueEntry(
149  active=True,
150  breaks_in_ha_version=breaks_in_ha_version,
151  created=dt_util.utcnow(),
152  data=data,
153  dismissed_version=None,
154  domain=domain,
155  is_fixable=is_fixable,
156  is_persistent=is_persistent,
157  issue_domain=issue_domain,
158  issue_id=issue_id,
159  learn_more_url=learn_more_url,
160  severity=severity,
161  translation_key=translation_key,
162  translation_placeholders=translation_placeholders,
163  )
164  self.issuesissues[(domain, issue_id)] = issue
165  self.async_schedule_save()
166  self.hasshass.bus.async_fire_internal(
167  EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED,
169  action="create",
170  domain=domain,
171  issue_id=issue_id,
172  ),
173  )
174  else:
175  replacement = dataclasses.replace(
176  issue,
177  active=True,
178  breaks_in_ha_version=breaks_in_ha_version,
179  data=data,
180  is_fixable=is_fixable,
181  is_persistent=is_persistent,
182  issue_domain=issue_domain,
183  learn_more_url=learn_more_url,
184  severity=severity,
185  translation_key=translation_key,
186  translation_placeholders=translation_placeholders,
187  )
188  # Only fire is something changed
189  if replacement != issue:
190  issue = self.issuesissues[(domain, issue_id)] = replacement
191  self.async_schedule_save()
192  self.hasshass.bus.async_fire_internal(
193  EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED,
195  action="update",
196  domain=domain,
197  issue_id=issue_id,
198  ),
199  )
200 
201  return issue
202 
203  @callback
204  def async_delete(self, domain: str, issue_id: str) -> None:
205  """Delete issue."""
206  self.hasshass.verify_event_loop_thread("issue_registry.async_delete")
207  if self.issuesissues.pop((domain, issue_id), None) is None:
208  return
209 
210  self.async_schedule_save()
211  self.hasshass.bus.async_fire_internal(
212  EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED,
214  action="remove",
215  domain=domain,
216  issue_id=issue_id,
217  ),
218  )
219 
220  @callback
221  def async_ignore(self, domain: str, issue_id: str, ignore: bool) -> IssueEntry:
222  """Ignore issue."""
223  self.hasshass.verify_event_loop_thread("issue_registry.async_ignore")
224  old = self.issuesissues[(domain, issue_id)]
225  dismissed_version = ha_version if ignore else None
226  if old.dismissed_version == dismissed_version:
227  return old
228 
229  issue = self.issuesissues[(domain, issue_id)] = dataclasses.replace(
230  old,
231  dismissed_version=dismissed_version,
232  )
233 
234  self.async_schedule_save()
235  self.hasshass.bus.async_fire_internal(
236  EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED,
238  action="update",
239  domain=domain,
240  issue_id=issue_id,
241  ),
242  )
243 
244  return issue
245 
246  @callback
247  def make_read_only(self) -> None:
248  """Make the registry read-only.
249 
250  This method is irreversible.
251  """
252  self._store_store.make_read_only()
253 
254  async def async_load(self) -> None:
255  """Load the issue registry."""
256  data = await self._store_store.async_load()
257 
258  issues: dict[tuple[str, str], IssueEntry] = {}
259 
260  if isinstance(data, dict):
261  for issue in data["issues"]:
262  created = cast(datetime, dt_util.parse_datetime(issue["created"]))
263  if issue["is_persistent"]:
264  issues[(issue["domain"], issue["issue_id"])] = IssueEntry(
265  active=True,
266  breaks_in_ha_version=issue["breaks_in_ha_version"],
267  created=created,
268  data=issue["data"],
269  dismissed_version=issue["dismissed_version"],
270  domain=issue["domain"],
271  is_fixable=issue["is_fixable"],
272  is_persistent=issue["is_persistent"],
273  issue_id=issue["issue_id"],
274  issue_domain=issue["issue_domain"],
275  learn_more_url=issue["learn_more_url"],
276  severity=issue["severity"],
277  translation_key=issue["translation_key"],
278  translation_placeholders=issue["translation_placeholders"],
279  )
280  else:
281  issues[(issue["domain"], issue["issue_id"])] = IssueEntry(
282  active=False,
283  breaks_in_ha_version=None,
284  created=created,
285  data=None,
286  dismissed_version=issue["dismissed_version"],
287  domain=issue["domain"],
288  is_fixable=None,
289  is_persistent=issue["is_persistent"],
290  issue_id=issue["issue_id"],
291  issue_domain=None,
292  learn_more_url=None,
293  severity=None,
294  translation_key=None,
295  translation_placeholders=None,
296  )
297 
298  self.issuesissues = issues
299 
300  @callback
301  def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]:
302  """Return data of issue registry to store in a file."""
303  data = {}
304 
305  data["issues"] = [entry.to_json() for entry in self.issuesissues.values()]
306 
307  return data
308 
309 
310 @callback
311 @singleton(DATA_REGISTRY)
312 def async_get(hass: HomeAssistant) -> IssueRegistry:
313  """Get issue registry."""
314  return IssueRegistry(hass)
315 
316 
317 async def async_load(hass: HomeAssistant, *, read_only: bool = False) -> None:
318  """Load issue registry."""
319  ir = async_get(hass)
320  if read_only: # only used in for check config script
321  ir.make_read_only()
322  return await ir.async_load()
323 
324 
325 @callback
327  hass: HomeAssistant,
328  domain: str,
329  issue_id: str,
330  *,
331  breaks_in_ha_version: str | None = None,
332  data: dict[str, str | int | float | None] | None = None,
333  is_fixable: bool,
334  is_persistent: bool = False,
335  issue_domain: str | None = None,
336  learn_more_url: str | None = None,
337  severity: IssueSeverity,
338  translation_key: str,
339  translation_placeholders: dict[str, str] | None = None,
340 ) -> None:
341  """Create an issue, or replace an existing one."""
342  # Verify the breaks_in_ha_version is a valid version string
343  if breaks_in_ha_version:
344  AwesomeVersion(
345  breaks_in_ha_version,
346  ensure_strategy=AwesomeVersionStrategy.CALVER,
347  )
348 
349  issue_registry = async_get(hass)
350  issue_registry.async_get_or_create(
351  domain,
352  issue_id,
353  breaks_in_ha_version=breaks_in_ha_version,
354  data=data,
355  is_fixable=is_fixable,
356  is_persistent=is_persistent,
357  issue_domain=issue_domain,
358  learn_more_url=learn_more_url,
359  severity=severity,
360  translation_key=translation_key,
361  translation_placeholders=translation_placeholders,
362  )
363 
364 
366  hass: HomeAssistant,
367  domain: str,
368  issue_id: str,
369  *,
370  breaks_in_ha_version: str | None = None,
371  data: dict[str, str | int | float | None] | None = None,
372  is_fixable: bool,
373  is_persistent: bool = False,
374  issue_domain: str | None = None,
375  learn_more_url: str | None = None,
376  severity: IssueSeverity,
377  translation_key: str,
378  translation_placeholders: dict[str, str] | None = None,
379 ) -> None:
380  """Create an issue, or replace an existing one."""
381  return run_callback_threadsafe(
382  hass.loop,
383  ft.partial(
384  async_create_issue,
385  hass,
386  domain,
387  issue_id,
388  breaks_in_ha_version=breaks_in_ha_version,
389  data=data,
390  is_fixable=is_fixable,
391  is_persistent=is_persistent,
392  issue_domain=issue_domain,
393  learn_more_url=learn_more_url,
394  severity=severity,
395  translation_key=translation_key,
396  translation_placeholders=translation_placeholders,
397  ),
398  ).result()
399 
400 
401 @callback
402 def async_delete_issue(hass: HomeAssistant, domain: str, issue_id: str) -> None:
403  """Delete an issue.
404 
405  It is not an error to delete an issue that does not exist.
406  """
407  issue_registry = async_get(hass)
408  issue_registry.async_delete(domain, issue_id)
409 
410 
411 def delete_issue(hass: HomeAssistant, domain: str, issue_id: str) -> None:
412  """Delete an issue.
413 
414  It is not an error to delete an issue that does not exist.
415  """
416  return run_callback_threadsafe(
417  hass.loop, async_delete_issue, hass, domain, issue_id
418  ).result()
419 
420 
421 @callback
423  hass: HomeAssistant, domain: str, issue_id: str, ignore: bool
424 ) -> None:
425  """Ignore an issue.
426 
427  Will raise if the issue does not exist.
428  """
429  issue_registry = async_get(hass)
430  issue_registry.async_ignore(domain, issue_id, ignore)
dict[str, Any] _async_migrate_func(self, int old_major_version, int old_minor_version, dict[str, Any] old_data)
None async_delete(self, str domain, str issue_id)
IssueEntry|None async_get_issue(self, str domain, str issue_id)
IssueEntry async_get_or_create(self, str domain, str issue_id, *str|None breaks_in_ha_version=None, dict[str, str|int|float|None]|None data=None, bool is_fixable, bool is_persistent, str|None issue_domain=None, str|None learn_more_url=None, IssueSeverity severity, str translation_key, dict[str, str]|None translation_placeholders=None)
IssueEntry async_ignore(self, str domain, str issue_id, bool ignore)
dict[str, list[dict[str, str|None]]] _data_to_save(self)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None delete_issue(HomeAssistant hass, str domain, str issue_id)
IssueRegistry async_get(HomeAssistant hass)
None async_create_issue(HomeAssistant hass, str domain, str issue_id, *str|None breaks_in_ha_version=None, dict[str, str|int|float|None]|None data=None, bool is_fixable, bool is_persistent=False, str|None issue_domain=None, str|None learn_more_url=None, IssueSeverity severity, str translation_key, dict[str, str]|None translation_placeholders=None)
None create_issue(HomeAssistant hass, str domain, str issue_id, *str|None breaks_in_ha_version=None, dict[str, str|int|float|None]|None data=None, bool is_fixable, bool is_persistent=False, str|None issue_domain=None, str|None learn_more_url=None, IssueSeverity severity, str translation_key, dict[str, str]|None translation_placeholders=None)
None async_load(HomeAssistant hass, *bool read_only=False)
None async_ignore_issue(HomeAssistant hass, str domain, str issue_id, bool ignore)
None async_delete_issue(HomeAssistant hass, str domain, str issue_id)