mirror of
https://github.com/home-assistant/core.git
synced 2025-07-30 02:38:10 +02:00
Add support for subentries to config entries (#117355)
* Add support for subentries to config entries * Improve error handling and test coverage * Include subentry_id in subentry containers * Auto-generate subentry_id and add optional unique_id * Tweak * Update tests * Fix stale docstring * Address review comments * Typing tweaks * Add methods to ConfigEntries to add and remove subentry * Improve ConfigSubentryData typed dict * Update test snapshots * Adjust tests * Fix unique_id logic * Allow multiple subentries with None unique_id * Add number of subentries to config entry JSON representation * Add subentry translation support * Allow integrations to implement multiple subentry flows * Update translations schema * Adjust exception text * Change subentry flow init step to user * Prevent creating a subentry with colliding unique_id * Update tests * Address review comments * Remove duplicaetd unique_id collision check * Remove change from the future * Improve test coverage * Add default value for unique_id
This commit is contained in:
@ -15,6 +15,7 @@ from collections.abc import (
|
||||
)
|
||||
from contextvars import ContextVar
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum, StrEnum
|
||||
import functools
|
||||
@ -22,7 +23,7 @@ from functools import cache
|
||||
import logging
|
||||
from random import randint
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Generic, Self, cast
|
||||
from typing import TYPE_CHECKING, Any, Generic, Self, TypedDict, cast
|
||||
|
||||
from async_interrupt import interrupt
|
||||
from propcache import cached_property
|
||||
@ -128,7 +129,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry()
|
||||
|
||||
STORAGE_KEY = "core.config_entries"
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 4
|
||||
STORAGE_VERSION_MINOR = 5
|
||||
|
||||
SAVE_DELAY = 1
|
||||
|
||||
@ -256,6 +257,10 @@ class UnknownEntry(ConfigError):
|
||||
"""Unknown entry specified."""
|
||||
|
||||
|
||||
class UnknownSubEntry(ConfigError):
|
||||
"""Unknown subentry specified."""
|
||||
|
||||
|
||||
class OperationNotAllowed(ConfigError):
|
||||
"""Raised when a config entry operation is not allowed."""
|
||||
|
||||
@ -300,6 +305,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
|
||||
|
||||
minor_version: int
|
||||
options: Mapping[str, Any]
|
||||
subentries: Iterable[ConfigSubentryData]
|
||||
version: int
|
||||
|
||||
|
||||
@ -313,6 +319,51 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N
|
||||
)
|
||||
|
||||
|
||||
class ConfigSubentryData(TypedDict):
|
||||
"""Container for configuration subentry data.
|
||||
|
||||
Returned by integrations, a subentry_id will be assigned automatically.
|
||||
"""
|
||||
|
||||
data: Mapping[str, Any]
|
||||
title: str
|
||||
unique_id: str | None
|
||||
|
||||
|
||||
class ConfigSubentryDataWithId(ConfigSubentryData):
|
||||
"""Container for configuration subentry data.
|
||||
|
||||
This type is used when loading existing subentries from storage.
|
||||
"""
|
||||
|
||||
subentry_id: str
|
||||
|
||||
|
||||
class SubentryFlowResult(FlowResult[FlowContext, tuple[str, str]], total=False):
|
||||
"""Typed result dict for subentry flow."""
|
||||
|
||||
unique_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ConfigSubentry:
|
||||
"""Container for a configuration subentry."""
|
||||
|
||||
data: MappingProxyType[str, Any]
|
||||
subentry_id: str = field(default_factory=ulid_util.ulid_now)
|
||||
title: str
|
||||
unique_id: str | None
|
||||
|
||||
def as_dict(self) -> ConfigSubentryDataWithId:
|
||||
"""Return dictionary version of this subentry."""
|
||||
return {
|
||||
"data": dict(self.data),
|
||||
"subentry_id": self.subentry_id,
|
||||
"title": self.title,
|
||||
"unique_id": self.unique_id,
|
||||
}
|
||||
|
||||
|
||||
class ConfigEntry(Generic[_DataT]):
|
||||
"""Hold a configuration entry."""
|
||||
|
||||
@ -322,6 +373,7 @@ class ConfigEntry(Generic[_DataT]):
|
||||
data: MappingProxyType[str, Any]
|
||||
runtime_data: _DataT
|
||||
options: MappingProxyType[str, Any]
|
||||
subentries: MappingProxyType[str, ConfigSubentry]
|
||||
unique_id: str | None
|
||||
state: ConfigEntryState
|
||||
reason: str | None
|
||||
@ -337,6 +389,7 @@ class ConfigEntry(Generic[_DataT]):
|
||||
supports_remove_device: bool | None
|
||||
_supports_options: bool | None
|
||||
_supports_reconfigure: bool | None
|
||||
_supported_subentries: tuple[str, ...] | None
|
||||
update_listeners: list[UpdateListenerType]
|
||||
_async_cancel_retry_setup: Callable[[], Any] | None
|
||||
_on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None
|
||||
@ -366,6 +419,7 @@ class ConfigEntry(Generic[_DataT]):
|
||||
pref_disable_polling: bool | None = None,
|
||||
source: str,
|
||||
state: ConfigEntryState = ConfigEntryState.NOT_LOADED,
|
||||
subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None,
|
||||
title: str,
|
||||
unique_id: str | None,
|
||||
version: int,
|
||||
@ -391,6 +445,24 @@ class ConfigEntry(Generic[_DataT]):
|
||||
# Entry options
|
||||
_setter(self, "options", MappingProxyType(options or {}))
|
||||
|
||||
# Subentries
|
||||
subentries_data = subentries_data or ()
|
||||
subentries = {}
|
||||
for subentry_data in subentries_data:
|
||||
subentry_kwargs = {}
|
||||
if "subentry_id" in subentry_data:
|
||||
# If subentry_data has key "subentry_id", we're loading from storage
|
||||
subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] # type: ignore[typeddict-item]
|
||||
subentry = ConfigSubentry(
|
||||
data=MappingProxyType(subentry_data["data"]),
|
||||
title=subentry_data["title"],
|
||||
unique_id=subentry_data.get("unique_id"),
|
||||
**subentry_kwargs,
|
||||
)
|
||||
subentries[subentry.subentry_id] = subentry
|
||||
|
||||
_setter(self, "subentries", MappingProxyType(subentries))
|
||||
|
||||
# Entry system options
|
||||
if pref_disable_new_entities is None:
|
||||
pref_disable_new_entities = False
|
||||
@ -427,6 +499,9 @@ class ConfigEntry(Generic[_DataT]):
|
||||
# Supports reconfigure
|
||||
_setter(self, "_supports_reconfigure", None)
|
||||
|
||||
# Supports subentries
|
||||
_setter(self, "_supported_subentries", None)
|
||||
|
||||
# Listeners to call on update
|
||||
_setter(self, "update_listeners", [])
|
||||
|
||||
@ -499,6 +574,18 @@ class ConfigEntry(Generic[_DataT]):
|
||||
)
|
||||
return self._supports_reconfigure or False
|
||||
|
||||
@property
|
||||
def supported_subentries(self) -> tuple[str, ...]:
|
||||
"""Return supported subentries."""
|
||||
if self._supported_subentries is None and (
|
||||
handler := HANDLERS.get(self.domain)
|
||||
):
|
||||
# work out sub entries supported by the handler
|
||||
object.__setattr__(
|
||||
self, "_supported_subentries", handler.async_supported_subentries(self)
|
||||
)
|
||||
return self._supported_subentries or ()
|
||||
|
||||
def clear_state_cache(self) -> None:
|
||||
"""Clear cached properties that are included in as_json_fragment."""
|
||||
self.__dict__.pop("as_json_fragment", None)
|
||||
@ -518,12 +605,14 @@ class ConfigEntry(Generic[_DataT]):
|
||||
"supports_remove_device": self.supports_remove_device or False,
|
||||
"supports_unload": self.supports_unload or False,
|
||||
"supports_reconfigure": self.supports_reconfigure,
|
||||
"supported_subentries": self.supported_subentries,
|
||||
"pref_disable_new_entities": self.pref_disable_new_entities,
|
||||
"pref_disable_polling": self.pref_disable_polling,
|
||||
"disabled_by": self.disabled_by,
|
||||
"reason": self.reason,
|
||||
"error_reason_translation_key": self.error_reason_translation_key,
|
||||
"error_reason_translation_placeholders": self.error_reason_translation_placeholders,
|
||||
"num_subentries": len(self.subentries),
|
||||
}
|
||||
return json_fragment(json_bytes(json_repr))
|
||||
|
||||
@ -1018,6 +1107,7 @@ class ConfigEntry(Generic[_DataT]):
|
||||
"pref_disable_new_entities": self.pref_disable_new_entities,
|
||||
"pref_disable_polling": self.pref_disable_polling,
|
||||
"source": self.source,
|
||||
"subentries": [subentry.as_dict() for subentry in self.subentries.values()],
|
||||
"title": self.title,
|
||||
"unique_id": self.unique_id,
|
||||
"version": self.version,
|
||||
@ -1503,6 +1593,7 @@ class ConfigEntriesFlowManager(
|
||||
minor_version=result["minor_version"],
|
||||
options=result["options"],
|
||||
source=flow.context["source"],
|
||||
subentries_data=result["subentries"],
|
||||
title=result["title"],
|
||||
unique_id=flow.unique_id,
|
||||
version=result["version"],
|
||||
@ -1793,6 +1884,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
||||
for entry in data["entries"]:
|
||||
entry["discovery_keys"] = {}
|
||||
|
||||
if old_minor_version < 5:
|
||||
# Version 1.4 adds config subentries
|
||||
for entry in data["entries"]:
|
||||
entry.setdefault("subentries", entry.get("subentries", {}))
|
||||
|
||||
if old_major_version > 1:
|
||||
raise NotImplementedError
|
||||
return data
|
||||
@ -1809,6 +1905,7 @@ class ConfigEntries:
|
||||
self.hass = hass
|
||||
self.flow = ConfigEntriesFlowManager(hass, self, hass_config)
|
||||
self.options = OptionsFlowManager(hass)
|
||||
self.subentries = ConfigSubentryFlowManager(hass)
|
||||
self._hass_config = hass_config
|
||||
self._entries = ConfigEntryItems(hass)
|
||||
self._store = ConfigEntryStore(hass)
|
||||
@ -2011,6 +2108,7 @@ class ConfigEntries:
|
||||
pref_disable_new_entities=entry["pref_disable_new_entities"],
|
||||
pref_disable_polling=entry["pref_disable_polling"],
|
||||
source=entry["source"],
|
||||
subentries_data=entry["subentries"],
|
||||
title=entry["title"],
|
||||
unique_id=entry["unique_id"],
|
||||
version=entry["version"],
|
||||
@ -2170,6 +2268,44 @@ class ConfigEntries:
|
||||
If the entry was changed, the update_listeners are
|
||||
fired and this function returns True
|
||||
|
||||
If the entry was not changed, the update_listeners are
|
||||
not fired and this function returns False
|
||||
"""
|
||||
return self._async_update_entry(
|
||||
entry,
|
||||
data=data,
|
||||
discovery_keys=discovery_keys,
|
||||
minor_version=minor_version,
|
||||
options=options,
|
||||
pref_disable_new_entities=pref_disable_new_entities,
|
||||
pref_disable_polling=pref_disable_polling,
|
||||
title=title,
|
||||
unique_id=unique_id,
|
||||
version=version,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_update_entry(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
*,
|
||||
data: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
||||
discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
|
||||
| UndefinedType = UNDEFINED,
|
||||
minor_version: int | UndefinedType = UNDEFINED,
|
||||
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
||||
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
||||
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
||||
subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED,
|
||||
title: str | UndefinedType = UNDEFINED,
|
||||
unique_id: str | None | UndefinedType = UNDEFINED,
|
||||
version: int | UndefinedType = UNDEFINED,
|
||||
) -> bool:
|
||||
"""Update a config entry.
|
||||
|
||||
If the entry was changed, the update_listeners are
|
||||
fired and this function returns True
|
||||
|
||||
If the entry was not changed, the update_listeners are
|
||||
not fired and this function returns False
|
||||
"""
|
||||
@ -2232,6 +2368,11 @@ class ConfigEntries:
|
||||
changed = True
|
||||
_setter(entry, "options", MappingProxyType(options))
|
||||
|
||||
if subentries is not UNDEFINED:
|
||||
if entry.subentries != subentries:
|
||||
changed = True
|
||||
_setter(entry, "subentries", MappingProxyType(subentries))
|
||||
|
||||
if not changed:
|
||||
return False
|
||||
|
||||
@ -2249,6 +2390,37 @@ class ConfigEntries:
|
||||
self._async_dispatch(ConfigEntryChange.UPDATED, entry)
|
||||
return True
|
||||
|
||||
@callback
|
||||
def async_add_subentry(self, entry: ConfigEntry, subentry: ConfigSubentry) -> bool:
|
||||
"""Add a subentry to a config entry."""
|
||||
self._raise_if_subentry_unique_id_exists(entry, subentry.unique_id)
|
||||
|
||||
return self._async_update_entry(
|
||||
entry,
|
||||
subentries=entry.subentries | {subentry.subentry_id: subentry},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_remove_subentry(self, entry: ConfigEntry, subentry_id: str) -> bool:
|
||||
"""Remove a subentry from a config entry."""
|
||||
subentries = dict(entry.subentries)
|
||||
try:
|
||||
subentries.pop(subentry_id)
|
||||
except KeyError as err:
|
||||
raise UnknownSubEntry from err
|
||||
|
||||
return self._async_update_entry(entry, subentries=subentries)
|
||||
|
||||
def _raise_if_subentry_unique_id_exists(
|
||||
self, entry: ConfigEntry, unique_id: str | None
|
||||
) -> None:
|
||||
"""Raise if a subentry with the same unique_id exists."""
|
||||
if unique_id is None:
|
||||
return
|
||||
for existing_subentry in entry.subentries.values():
|
||||
if existing_subentry.unique_id == unique_id:
|
||||
raise data_entry_flow.AbortFlow("already_configured")
|
||||
|
||||
@callback
|
||||
def _async_dispatch(
|
||||
self, change_type: ConfigEntryChange, entry: ConfigEntry
|
||||
@ -2585,6 +2757,20 @@ class ConfigFlow(ConfigEntryBaseFlow):
|
||||
"""Return options flow support for this handler."""
|
||||
return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_subentry_flow(
|
||||
config_entry: ConfigEntry, subentry_type: str
|
||||
) -> ConfigSubentryFlow:
|
||||
"""Get the subentry flow for this handler."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_supported_subentries(cls, config_entry: ConfigEntry) -> tuple[str, ...]:
|
||||
"""Return subentries supported by this handler."""
|
||||
return ()
|
||||
|
||||
@callback
|
||||
def _async_abort_entries_match(
|
||||
self, match_dict: dict[str, Any] | None = None
|
||||
@ -2893,6 +3079,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
|
||||
description: str | None = None,
|
||||
description_placeholders: Mapping[str, str] | None = None,
|
||||
options: Mapping[str, Any] | None = None,
|
||||
subentries: Iterable[ConfigSubentryData] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Finish config flow and create a config entry."""
|
||||
if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
|
||||
@ -2912,6 +3099,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
|
||||
|
||||
result["minor_version"] = self.MINOR_VERSION
|
||||
result["options"] = options or {}
|
||||
result["subentries"] = subentries or ()
|
||||
result["version"] = self.VERSION
|
||||
|
||||
return result
|
||||
@ -3026,17 +3214,126 @@ class ConfigFlow(ConfigEntryBaseFlow):
|
||||
)
|
||||
|
||||
|
||||
class OptionsFlowManager(
|
||||
data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult]
|
||||
):
|
||||
"""Flow to set options for a configuration entry."""
|
||||
class _ConfigSubFlowManager:
|
||||
"""Mixin class for flow managers which manage flows tied to a config entry."""
|
||||
|
||||
_flow_result = ConfigFlowResult
|
||||
hass: HomeAssistant
|
||||
|
||||
def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry:
|
||||
"""Return config entry or raise if not found."""
|
||||
return self.hass.config_entries.async_get_known_entry(config_entry_id)
|
||||
|
||||
|
||||
class ConfigSubentryFlowManager(
|
||||
data_entry_flow.FlowManager[FlowContext, SubentryFlowResult, tuple[str, str]],
|
||||
_ConfigSubFlowManager,
|
||||
):
|
||||
"""Manage all the config subentry flows that are in progress."""
|
||||
|
||||
_flow_result = SubentryFlowResult
|
||||
|
||||
async def async_create_flow(
|
||||
self,
|
||||
handler_key: tuple[str, str],
|
||||
*,
|
||||
context: FlowContext | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> ConfigSubentryFlow:
|
||||
"""Create a subentry flow for a config entry.
|
||||
|
||||
The entry_id and flow.handler[0] is the same thing to map entry with flow.
|
||||
"""
|
||||
if not context or "source" not in context:
|
||||
raise KeyError("Context not set or doesn't have a source set")
|
||||
|
||||
entry_id, subentry_type = handler_key
|
||||
entry = self._async_get_config_entry(entry_id)
|
||||
handler = await _async_get_flow_handler(self.hass, entry.domain, {})
|
||||
if subentry_type not in handler.async_supported_subentries(entry):
|
||||
raise data_entry_flow.UnknownHandler(
|
||||
f"Config entry '{entry.domain}' does not support subentry '{subentry_type}'"
|
||||
)
|
||||
subentry_flow = handler.async_get_subentry_flow(entry, subentry_type)
|
||||
subentry_flow.init_step = context["source"]
|
||||
return subentry_flow
|
||||
|
||||
async def async_finish_flow(
|
||||
self,
|
||||
flow: data_entry_flow.FlowHandler[
|
||||
FlowContext, SubentryFlowResult, tuple[str, str]
|
||||
],
|
||||
result: SubentryFlowResult,
|
||||
) -> SubentryFlowResult:
|
||||
"""Finish a subentry flow and add a new subentry to the configuration entry.
|
||||
|
||||
The flow.handler[0] and entry_id is the same thing to map flow with entry.
|
||||
"""
|
||||
flow = cast(ConfigSubentryFlow, flow)
|
||||
|
||||
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
return result
|
||||
|
||||
entry_id = flow.handler[0]
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
raise UnknownEntry(entry_id)
|
||||
|
||||
unique_id = result.get("unique_id")
|
||||
if unique_id is not None and not isinstance(unique_id, str):
|
||||
raise HomeAssistantError("unique_id must be a string")
|
||||
|
||||
self.hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(result["data"]),
|
||||
title=result["title"],
|
||||
unique_id=unique_id,
|
||||
),
|
||||
)
|
||||
|
||||
result["result"] = True
|
||||
return result
|
||||
|
||||
|
||||
class ConfigSubentryFlow(
|
||||
data_entry_flow.FlowHandler[FlowContext, SubentryFlowResult, tuple[str, str]]
|
||||
):
|
||||
"""Base class for config subentry flows."""
|
||||
|
||||
_flow_result = SubentryFlowResult
|
||||
handler: tuple[str, str]
|
||||
|
||||
@callback
|
||||
def async_create_entry(
|
||||
self,
|
||||
*,
|
||||
title: str | None = None,
|
||||
data: Mapping[str, Any],
|
||||
description: str | None = None,
|
||||
description_placeholders: Mapping[str, str] | None = None,
|
||||
unique_id: str | None = None,
|
||||
) -> SubentryFlowResult:
|
||||
"""Finish config flow and create a config entry."""
|
||||
result = super().async_create_entry(
|
||||
title=title,
|
||||
data=data,
|
||||
description=description,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
result["unique_id"] = unique_id
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class OptionsFlowManager(
|
||||
data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult],
|
||||
_ConfigSubFlowManager,
|
||||
):
|
||||
"""Manage all the config entry option flows that are in progress."""
|
||||
|
||||
_flow_result = ConfigFlowResult
|
||||
|
||||
async def async_create_flow(
|
||||
self,
|
||||
handler_key: str,
|
||||
@ -3046,7 +3343,7 @@ class OptionsFlowManager(
|
||||
) -> OptionsFlow:
|
||||
"""Create an options flow for a config entry.
|
||||
|
||||
Entry_id and flow.handler is the same thing to map entry with flow.
|
||||
The entry_id and the flow.handler is the same thing to map entry with flow.
|
||||
"""
|
||||
entry = self._async_get_config_entry(handler_key)
|
||||
handler = await _async_get_flow_handler(self.hass, entry.domain, {})
|
||||
@ -3062,7 +3359,7 @@ class OptionsFlowManager(
|
||||
This method is called when a flow step returns FlowResultType.ABORT or
|
||||
FlowResultType.CREATE_ENTRY.
|
||||
|
||||
Flow.handler and entry_id is the same thing to map flow with entry.
|
||||
The flow.handler and the entry_id is the same thing to map flow with entry.
|
||||
"""
|
||||
flow = cast(OptionsFlow, flow)
|
||||
|
||||
|
Reference in New Issue
Block a user