Home Assistant Unofficial Reference 2024.12.1
category_registry.py
Go to the documentation of this file.
1 """Provide a way to categorize things within a defined scope."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Iterable
6 import dataclasses
7 from dataclasses import dataclass, field
8 from datetime import datetime
9 from typing import Any, Literal, TypedDict
10 
11 from homeassistant.core import Event, HomeAssistant, callback
12 from homeassistant.util.dt import utc_from_timestamp, utcnow
13 from homeassistant.util.event_type import EventType
14 from homeassistant.util.hass_dict import HassKey
15 from homeassistant.util.ulid import ulid_now
16 
17 from .registry import BaseRegistry
18 from .singleton import singleton
19 from .storage import Store
20 from .typing import UNDEFINED, UndefinedType
21 
22 DATA_REGISTRY: HassKey[CategoryRegistry] = HassKey("category_registry")
23 EVENT_CATEGORY_REGISTRY_UPDATED: EventType[EventCategoryRegistryUpdatedData] = (
24  EventType("category_registry_updated")
25 )
26 STORAGE_KEY = "core.category_registry"
27 STORAGE_VERSION_MAJOR = 1
28 STORAGE_VERSION_MINOR = 2
29 
30 
31 class _CategoryStoreData(TypedDict):
32  """Data type for individual category. Used in CategoryRegistryStoreData."""
33 
34  category_id: str
35  created_at: str
36  icon: str | None
37  modified_at: str
38  name: str
39 
40 
41 class CategoryRegistryStoreData(TypedDict):
42  """Store data type for CategoryRegistry."""
43 
44  categories: dict[str, list[_CategoryStoreData]]
45 
46 
48  """Event data for when the category registry is updated."""
49 
50  action: Literal["create", "remove", "update"]
51  scope: str
52  category_id: str
53 
54 
55 type EventCategoryRegistryUpdated = Event[EventCategoryRegistryUpdatedData]
56 
57 
58 @dataclass(slots=True, kw_only=True, frozen=True)
60  """Category registry entry."""
61 
62  category_id: str = field(default_factory=ulid_now)
63  created_at: datetime = field(default_factory=utcnow)
64  icon: str | None = None
65  modified_at: datetime = field(default_factory=utcnow)
66  name: str
67 
68 
69 class CategoryRegistryStore(Store[CategoryRegistryStoreData]):
70  """Store category registry data."""
71 
73  self,
74  old_major_version: int,
75  old_minor_version: int,
76  old_data: dict[str, dict[str, list[dict[str, Any]]]],
77  ) -> CategoryRegistryStoreData:
78  """Migrate to the new version."""
79  if old_major_version > STORAGE_VERSION_MAJOR:
80  raise ValueError("Can't migrate to future version")
81 
82  if old_major_version == 1:
83  if old_minor_version < 2:
84  # Version 1.2 implements migration and adds created_at and modified_at
85  created_at = utc_from_timestamp(0).isoformat()
86  for categories in old_data["categories"].values():
87  for category in categories:
88  category["created_at"] = category["modified_at"] = created_at
89 
90  return old_data # type: ignore[return-value]
91 
92 
93 class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]):
94  """Class to hold a registry of categories by scope."""
95 
96  def __init__(self, hass: HomeAssistant) -> None:
97  """Initialize the category registry."""
98  self.hasshass = hass
99  self.categoriescategories: dict[str, dict[str, CategoryEntry]] = {}
101  hass,
102  STORAGE_VERSION_MAJOR,
103  STORAGE_KEY,
104  atomic_writes=True,
105  minor_version=STORAGE_VERSION_MINOR,
106  )
107 
108  @callback
110  self, *, scope: str, category_id: str
111  ) -> CategoryEntry | None:
112  """Get category by ID."""
113  if scope not in self.categoriescategories:
114  return None
115  return self.categoriescategories[scope].get(category_id)
116 
117  @callback
118  def async_list_categories(self, *, scope: str) -> Iterable[CategoryEntry]:
119  """Get all categories."""
120  if scope not in self.categoriescategories:
121  return []
122  return self.categoriescategories[scope].values()
123 
124  @callback
126  self,
127  *,
128  name: str,
129  scope: str,
130  icon: str | None = None,
131  ) -> CategoryEntry:
132  """Create a new category."""
133  self.hasshass.verify_event_loop_thread("category_registry.async_create")
134  self._async_ensure_name_is_available_async_ensure_name_is_available(scope, name)
135  category = CategoryEntry(
136  icon=icon,
137  name=name,
138  )
139 
140  if scope not in self.categoriescategories:
141  self.categoriescategories[scope] = {}
142 
143  self.categoriescategories[scope][category.category_id] = category
144 
145  self.async_schedule_save()
146  self.hasshass.bus.async_fire_internal(
147  EVENT_CATEGORY_REGISTRY_UPDATED,
149  action="create", scope=scope, category_id=category.category_id
150  ),
151  )
152  return category
153 
154  @callback
155  def async_delete(self, *, scope: str, category_id: str) -> None:
156  """Delete category."""
157  self.hasshass.verify_event_loop_thread("category_registry.async_delete")
158  del self.categoriescategories[scope][category_id]
159  self.hasshass.bus.async_fire_internal(
160  EVENT_CATEGORY_REGISTRY_UPDATED,
162  action="remove",
163  scope=scope,
164  category_id=category_id,
165  ),
166  )
167  self.async_schedule_save()
168 
169  @callback
171  self,
172  *,
173  scope: str,
174  category_id: str,
175  icon: str | None | UndefinedType = UNDEFINED,
176  name: str | UndefinedType = UNDEFINED,
177  ) -> CategoryEntry:
178  """Update name or icon of the category."""
179  old = self.categoriescategories[scope][category_id]
180  changes: dict[str, Any] = {}
181 
182  if icon is not UNDEFINED and icon != old.icon:
183  changes["icon"] = icon
184 
185  if name is not UNDEFINED and name != old.name:
186  changes["name"] = name
187  self._async_ensure_name_is_available_async_ensure_name_is_available(scope, name, category_id)
188 
189  if not changes:
190  return old
191 
192  changes["modified_at"] = utcnow()
193 
194  self.hasshass.verify_event_loop_thread("category_registry.async_update")
195  new = self.categoriescategories[scope][category_id] = dataclasses.replace(old, **changes)
196 
197  self.async_schedule_save()
198  self.hasshass.bus.async_fire_internal(
199  EVENT_CATEGORY_REGISTRY_UPDATED,
201  action="update", scope=scope, category_id=category_id
202  ),
203  )
204 
205  return new
206 
207  async def async_load(self) -> None:
208  """Load the category registry."""
209  data = await self._store_store.async_load()
210  category_entries: dict[str, dict[str, CategoryEntry]] = {}
211 
212  if data is not None:
213  for scope, categories in data["categories"].items():
214  category_entries[scope] = {
215  category["category_id"]: CategoryEntry(
216  category_id=category["category_id"],
217  created_at=datetime.fromisoformat(category["created_at"]),
218  icon=category["icon"],
219  modified_at=datetime.fromisoformat(category["modified_at"]),
220  name=category["name"],
221  )
222  for category in categories
223  }
224 
225  self.categoriescategories = category_entries
226 
227  @callback
228  def _data_to_save(self) -> CategoryRegistryStoreData:
229  """Return data of category registry to store in a file."""
230  return {
231  "categories": {
232  scope: [
233  {
234  "category_id": entry.category_id,
235  "created_at": entry.created_at.isoformat(),
236  "icon": entry.icon,
237  "modified_at": entry.modified_at.isoformat(),
238  "name": entry.name,
239  }
240  for entry in entries.values()
241  ]
242  for scope, entries in self.categoriescategories.items()
243  }
244  }
245 
246  @callback
248  self, scope: str, name: str, category_id: str | None = None
249  ) -> None:
250  """Ensure name is available within the scope."""
251  if scope not in self.categoriescategories:
252  return
253  for category in self.categoriescategories[scope].values():
254  if (
255  category.name.casefold() == name.casefold()
256  and category.category_id != category_id
257  ):
258  raise ValueError(f"The name '{name}' is already in use")
259 
260 
261 @callback
262 @singleton(DATA_REGISTRY)
263 def async_get(hass: HomeAssistant) -> CategoryRegistry:
264  """Get category registry."""
265  return CategoryRegistry(hass)
266 
267 
268 async def async_load(hass: HomeAssistant) -> None:
269  """Load category registry."""
270  assert DATA_REGISTRY not in hass.data
271  await async_get(hass).async_load()
CategoryRegistryStoreData _async_migrate_func(self, int old_major_version, int old_minor_version, dict[str, dict[str, list[dict[str, Any]]]] old_data)
CategoryEntry|None async_get_category(self, *str scope, str category_id)
Iterable[CategoryEntry] async_list_categories(self, *str scope)
CategoryEntry async_update(self, *str scope, str category_id, str|None|UndefinedType icon=UNDEFINED, str|UndefinedType name=UNDEFINED)
None _async_ensure_name_is_available(self, str scope, str name, str|None category_id=None)
None async_delete(self, *str scope, str category_id)
CategoryEntry async_create(self, *str name, str scope, str|None icon=None)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
CategoryRegistry async_get(HomeAssistant hass)