1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 02:48:57 +00:00

Fix use of storage helper in the labs integration (#157249)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Erik Montnemery
2025-11-25 13:52:02 +01:00
committed by GitHub
parent 4be1fa9a3a
commit d4db5ec0cc
5 changed files with 132 additions and 125 deletions

View File

@@ -32,6 +32,7 @@ from .const import (
LabPreviewFeature,
LabsData,
LabsStoreData,
NativeLabsStoreData,
)
_LOGGER = logging.getLogger(__name__)
@@ -46,66 +47,12 @@ __all__ = [
]
class LabsStorage(Store[LabsStoreData]):
"""Custom Store for Labs that converts between runtime and storage formats.
Runtime format: {"preview_feature_status": {(domain, preview_feature)}}
Storage format: {"preview_feature_status": [{"domain": str, "preview_feature": str}]}
Only enabled features are saved to storage - if stored, it's enabled.
"""
async def _async_load_data(self) -> LabsStoreData | None:
"""Load data and convert from storage format to runtime format."""
raw_data = await super()._async_load_data()
if raw_data is None:
return None
status_list = raw_data.get("preview_feature_status", [])
# Convert list of objects to runtime set - if stored, it's enabled
return {
"preview_feature_status": {
(item["domain"], item["preview_feature"]) for item in status_list
}
}
def _write_data(self, path: str, data: dict) -> None:
"""Convert from runtime format to storage format and write.
Only saves enabled features - disabled is the default.
"""
# Extract the actual data (has version/key wrapper)
actual_data = data.get("data", data)
# Check if this is Labs data (has preview_feature_status key)
if "preview_feature_status" not in actual_data:
# Not Labs data, write as-is
super()._write_data(path, data)
return
preview_status = actual_data["preview_feature_status"]
# Convert from runtime format (set of tuples) to storage format (list of dicts)
status_list = [
{"domain": domain, "preview_feature": preview_feature}
for domain, preview_feature in preview_status
]
# Build the final data structure with converted format
data_copy = data.copy()
data_copy["data"] = {"preview_feature_status": status_list}
super()._write_data(path, data_copy)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Labs component."""
store = LabsStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True)
data = await store.async_load()
if data is None:
data = {"preview_feature_status": set()}
store: Store[NativeLabsStoreData] = Store(
hass, STORAGE_VERSION, STORAGE_KEY, private=True
)
data = LabsStoreData.from_store_format(await store.async_load())
# Scan ALL integrations for lab preview features (loaded or not)
lab_preview_features = await _async_scan_all_preview_features(hass)
@@ -115,7 +62,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
valid_keys = {
(pf.domain, pf.preview_feature) for pf in lab_preview_features.values()
}
stale_keys = data["preview_feature_status"] - valid_keys
stale_keys = data.preview_feature_status - valid_keys
if stale_keys:
_LOGGER.debug(
@@ -123,9 +70,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
len(stale_keys),
stale_keys,
)
data["preview_feature_status"] -= stale_keys
data.preview_feature_status -= stale_keys
await store.async_save(data)
await store.async_save(data.to_store_format())
hass.data[LABS_DATA] = LabsData(
store=store,
@@ -216,7 +163,7 @@ def async_is_preview_feature_enabled(
return False
labs_data = hass.data[LABS_DATA]
return (domain, preview_feature) in labs_data.data["preview_feature_status"]
return (domain, preview_feature) in labs_data.data.preview_feature_status
@callback
@@ -265,7 +212,7 @@ def websocket_list_preview_features(
preview_features: list[dict[str, Any]] = [
preview_feature.to_dict(
(preview_feature.domain, preview_feature.preview_feature)
in labs_data.data["preview_feature_status"]
in labs_data.data.preview_feature_status
)
for preview_feature in labs_data.preview_features.values()
if preview_feature.domain in loaded_components
@@ -325,12 +272,12 @@ async def websocket_update_preview_feature(
# Update storage (only store enabled features, remove if disabled)
if enabled:
labs_data.data["preview_feature_status"].add((domain, preview_feature))
labs_data.data.preview_feature_status.add((domain, preview_feature))
else:
labs_data.data["preview_feature_status"].discard((domain, preview_feature))
labs_data.data.preview_feature_status.discard((domain, preview_feature))
# Save changes immediately
await labs_data.store.async_save(labs_data.data)
await labs_data.store.async_save(labs_data.data.to_store_format())
# Fire event
event_data: EventLabsUpdatedData = {

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, TypedDict
from typing import TYPE_CHECKING, Self, TypedDict
from homeassistant.util.hass_dict import HassKey
@@ -62,14 +62,52 @@ class LabPreviewFeature:
}
type LabsStoreData = dict[str, set[tuple[str, str]]]
@dataclass(kw_only=True)
class LabsStoreData:
"""Storage data for Labs."""
preview_feature_status: set[tuple[str, str]]
@classmethod
def from_store_format(cls, data: NativeLabsStoreData | None) -> Self:
"""Initialize from storage format."""
if data is None:
return cls(preview_feature_status=set())
return cls(
preview_feature_status={
(item["domain"], item["preview_feature"])
for item in data["preview_feature_status"]
}
)
def to_store_format(self) -> NativeLabsStoreData:
"""Convert to storage format."""
return {
"preview_feature_status": [
{"domain": domain, "preview_feature": preview_feature}
for domain, preview_feature in self.preview_feature_status
]
}
class NativeLabsStoreData(TypedDict):
"""Storage data for Labs."""
preview_feature_status: list[NativeLabsStoredFeature]
class NativeLabsStoredFeature(TypedDict):
"""A single preview feature entry in storage format."""
domain: str
preview_feature: str
@dataclass
class LabsData:
"""Storage class for Labs global data."""
store: Store[LabsStoreData]
store: Store[NativeLabsStoreData]
data: LabsStoreData
preview_features: dict[str, LabPreviewFeature] = field(default_factory=dict)

View File

@@ -1 +1,18 @@
"""Tests for the Home Assistant Labs integration."""
from typing import Any
from pytest_unordered import unordered
def assert_stored_labs_data(
hass_storage: dict[str, Any],
expected_data: list[dict[str, str]],
) -> None:
"""Assert that the storage has the expected enabled preview features."""
assert hass_storage["core.labs"] == {
"version": 1,
"minor_version": 1,
"key": "core.labs",
"data": {"preview_feature_status": unordered(expected_data)},
}

View File

@@ -9,16 +9,16 @@ import pytest
from homeassistant.components.labs import (
EVENT_LABS_UPDATED,
LabsStorage,
async_is_preview_feature_enabled,
async_listen,
)
from homeassistant.components.labs.const import DOMAIN, LABS_DATA, LabPreviewFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.loader import Integration
from homeassistant.setup import async_setup_component
from . import assert_stored_labs_data
async def test_async_setup(hass: HomeAssistant) -> None:
"""Test the Labs integration setup."""
@@ -91,7 +91,12 @@ async def test_async_is_preview_feature_enabled_when_disabled(
@pytest.mark.parametrize(
("features_to_store", "expected_enabled", "expected_cleaned"),
(
"features_to_store",
"expected_enabled",
"expected_cleaned",
"expected_cleaned_store",
),
[
# Single stale feature cleanup
(
@@ -101,6 +106,7 @@ async def test_async_is_preview_feature_enabled_when_disabled(
],
[("kitchen_sink", "special_repair")],
[("nonexistent_domain", "fake_feature")],
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
),
# Multiple stale features cleanup
(
@@ -116,12 +122,14 @@ async def test_async_is_preview_feature_enabled_when_disabled(
("stale_domain_2", "another_old"),
("stale_domain_3", "yet_another"),
],
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
),
# All features cleaned (no integrations loaded)
(
[{"domain": "nonexistent", "preview_feature": "fake"}],
[],
[("nonexistent", "fake")],
[],
),
],
)
@@ -131,6 +139,7 @@ async def test_storage_cleanup_stale_features(
features_to_store: list[dict[str, str]],
expected_enabled: list[tuple[str, str]],
expected_cleaned: list[tuple[str, str]],
expected_cleaned_store: list[dict[str, str]],
) -> None:
"""Test that stale preview features are removed from storage on setup."""
# Load kitchen_sink only if we expect any features to remain
@@ -155,6 +164,8 @@ async def test_storage_cleanup_stale_features(
for domain, feature in expected_cleaned:
assert not async_is_preview_feature_enabled(hass, domain, feature)
assert_stored_labs_data(hass_storage, expected_cleaned_store)
@pytest.mark.parametrize(
("domain", "preview_feature", "expected"),
@@ -191,37 +202,6 @@ async def test_async_is_preview_feature_enabled(
assert result is expected
async def test_multiple_setups_idempotent(hass: HomeAssistant) -> None:
"""Test that calling async_setup multiple times is safe."""
result1 = await async_setup_component(hass, DOMAIN, {})
assert result1 is True
result2 = await async_setup_component(hass, DOMAIN, {})
assert result2 is True
# Verify store is still accessible
assert LABS_DATA in hass.data
async def test_storage_load_missing_preview_feature_status_key(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test loading storage when preview_feature_status key is missing."""
# Storage data without preview_feature_status key
hass_storage["core.labs"] = {
"version": 1,
"minor_version": 1,
"key": "core.labs",
"data": {}, # Missing preview_feature_status
}
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
# Should initialize correctly - verify no feature is enabled
assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
async def test_preview_feature_full_key(hass: HomeAssistant) -> None:
"""Test that preview feature full_key property returns correct format."""
feature = LabPreviewFeature(
@@ -276,24 +256,6 @@ async def test_preview_feature_to_dict_with_no_urls(hass: HomeAssistant) -> None
}
async def test_storage_load_returns_none_when_no_file(
hass: HomeAssistant,
) -> None:
"""Test storage load when no file exists (returns None)."""
# Create a storage instance but don't write any data
store = LabsStorage(hass, 1, "test_labs_none.json")
# Mock the parent Store's _async_load_data to return None
# This simulates the edge case where Store._async_load_data returns None
# This tests line 60: return None
async def mock_load_none():
return None
with patch.object(Store, "_async_load_data", new=mock_load_none):
result = await store.async_load()
assert result is None
async def test_custom_integration_with_preview_features(
hass: HomeAssistant,
) -> None:

View File

@@ -14,6 +14,8 @@ from homeassistant.components.labs import (
)
from homeassistant.core import HomeAssistant
from . import assert_stored_labs_data
from tests.common import MockUser
from tests.typing import WebSocketGenerator
@@ -61,7 +63,9 @@ async def test_websocket_list_preview_features(
async def test_websocket_update_preview_feature_enable(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test enabling a preview feature via WebSocket."""
# Load kitchen_sink integration
@@ -71,6 +75,8 @@ async def test_websocket_update_preview_feature_enable(
client = await hass_ws_client(hass)
assert "core.labs" not in hass_storage
# Track events
events = []
@@ -103,6 +109,11 @@ async def test_websocket_update_preview_feature_enable(
# Verify feature is now enabled
assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
assert_stored_labs_data(
hass_storage,
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
)
async def test_websocket_update_preview_feature_disable(
hass: HomeAssistant,
@@ -143,10 +154,16 @@ async def test_websocket_update_preview_feature_disable(
# Verify feature is disabled
assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
assert_stored_labs_data(
hass_storage,
[],
)
async def test_websocket_update_nonexistent_feature(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test updating a preview feature that doesn't exist."""
assert await async_setup(hass, {})
@@ -168,9 +185,13 @@ async def test_websocket_update_nonexistent_feature(
assert msg["error"]["code"] == "not_found"
assert "not found" in msg["error"]["message"].lower()
assert "core.labs" not in hass_storage
async def test_websocket_update_unavailable_preview_feature(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test updating a preview feature whose integration is not loaded still works."""
# Don't load kitchen_sink integration
@@ -193,6 +214,11 @@ async def test_websocket_update_unavailable_preview_feature(
assert msg["success"]
assert msg["result"] is None
assert_stored_labs_data(
hass_storage,
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
)
@pytest.mark.parametrize(
"command_type",
@@ -202,6 +228,7 @@ async def test_websocket_requires_admin(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
hass_storage: dict[str, Any],
command_type: str,
) -> None:
"""Test that websocket commands require admin privileges."""
@@ -230,6 +257,8 @@ async def test_websocket_requires_admin(
assert not msg["success"]
assert msg["error"]["code"] == "unauthorized"
assert "core.labs" not in hass_storage
async def test_websocket_update_validates_enabled_parameter(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
@@ -257,7 +286,9 @@ async def test_websocket_update_validates_enabled_parameter(
async def test_storage_persists_preview_feature_across_calls(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test that storage persists preview feature state across multiple calls."""
hass.config.components.add("kitchen_sink")
@@ -266,6 +297,8 @@ async def test_storage_persists_preview_feature_across_calls(
client = await hass_ws_client(hass)
assert "core.labs" not in hass_storage
# Enable the preview feature
await client.send_json_auto_id(
{
@@ -278,6 +311,11 @@ async def test_storage_persists_preview_feature_across_calls(
msg = await client.receive_json()
assert msg["success"]
assert_stored_labs_data(
hass_storage,
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
)
# List preview features - should show enabled
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()
@@ -296,6 +334,11 @@ async def test_storage_persists_preview_feature_across_calls(
msg = await client.receive_json()
assert msg["success"]
assert_stored_labs_data(
hass_storage,
[],
)
# List preview features - should show disabled
await client.send_json_auto_id({"type": "labs/list"})
msg = await client.receive_json()