Skip to content

Commit d4db5ec

Browse files
emontnemeryCopilot
andauthored
Fix use of storage helper in the labs integration (home-assistant#157249)
Co-authored-by: Copilot <[email protected]>
1 parent 4be1fa9 commit d4db5ec

File tree

5 files changed

+132
-125
lines changed

5 files changed

+132
-125
lines changed

homeassistant/components/labs/__init__.py

Lines changed: 13 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
LabPreviewFeature,
3333
LabsData,
3434
LabsStoreData,
35+
NativeLabsStoreData,
3536
)
3637

3738
_LOGGER = logging.getLogger(__name__)
@@ -46,66 +47,12 @@
4647
]
4748

4849

49-
class LabsStorage(Store[LabsStoreData]):
50-
"""Custom Store for Labs that converts between runtime and storage formats.
51-
52-
Runtime format: {"preview_feature_status": {(domain, preview_feature)}}
53-
Storage format: {"preview_feature_status": [{"domain": str, "preview_feature": str}]}
54-
55-
Only enabled features are saved to storage - if stored, it's enabled.
56-
"""
57-
58-
async def _async_load_data(self) -> LabsStoreData | None:
59-
"""Load data and convert from storage format to runtime format."""
60-
raw_data = await super()._async_load_data()
61-
if raw_data is None:
62-
return None
63-
64-
status_list = raw_data.get("preview_feature_status", [])
65-
66-
# Convert list of objects to runtime set - if stored, it's enabled
67-
return {
68-
"preview_feature_status": {
69-
(item["domain"], item["preview_feature"]) for item in status_list
70-
}
71-
}
72-
73-
def _write_data(self, path: str, data: dict) -> None:
74-
"""Convert from runtime format to storage format and write.
75-
76-
Only saves enabled features - disabled is the default.
77-
"""
78-
# Extract the actual data (has version/key wrapper)
79-
actual_data = data.get("data", data)
80-
81-
# Check if this is Labs data (has preview_feature_status key)
82-
if "preview_feature_status" not in actual_data:
83-
# Not Labs data, write as-is
84-
super()._write_data(path, data)
85-
return
86-
87-
preview_status = actual_data["preview_feature_status"]
88-
89-
# Convert from runtime format (set of tuples) to storage format (list of dicts)
90-
status_list = [
91-
{"domain": domain, "preview_feature": preview_feature}
92-
for domain, preview_feature in preview_status
93-
]
94-
95-
# Build the final data structure with converted format
96-
data_copy = data.copy()
97-
data_copy["data"] = {"preview_feature_status": status_list}
98-
99-
super()._write_data(path, data_copy)
100-
101-
10250
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
10351
"""Set up the Labs component."""
104-
store = LabsStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True)
105-
data = await store.async_load()
106-
107-
if data is None:
108-
data = {"preview_feature_status": set()}
52+
store: Store[NativeLabsStoreData] = Store(
53+
hass, STORAGE_VERSION, STORAGE_KEY, private=True
54+
)
55+
data = LabsStoreData.from_store_format(await store.async_load())
10956

11057
# Scan ALL integrations for lab preview features (loaded or not)
11158
lab_preview_features = await _async_scan_all_preview_features(hass)
@@ -115,17 +62,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
11562
valid_keys = {
11663
(pf.domain, pf.preview_feature) for pf in lab_preview_features.values()
11764
}
118-
stale_keys = data["preview_feature_status"] - valid_keys
65+
stale_keys = data.preview_feature_status - valid_keys
11966

12067
if stale_keys:
12168
_LOGGER.debug(
12269
"Removing %d stale preview features: %s",
12370
len(stale_keys),
12471
stale_keys,
12572
)
126-
data["preview_feature_status"] -= stale_keys
73+
data.preview_feature_status -= stale_keys
12774

128-
await store.async_save(data)
75+
await store.async_save(data.to_store_format())
12976

13077
hass.data[LABS_DATA] = LabsData(
13178
store=store,
@@ -216,7 +163,7 @@ def async_is_preview_feature_enabled(
216163
return False
217164

218165
labs_data = hass.data[LABS_DATA]
219-
return (domain, preview_feature) in labs_data.data["preview_feature_status"]
166+
return (domain, preview_feature) in labs_data.data.preview_feature_status
220167

221168

222169
@callback
@@ -265,7 +212,7 @@ def websocket_list_preview_features(
265212
preview_features: list[dict[str, Any]] = [
266213
preview_feature.to_dict(
267214
(preview_feature.domain, preview_feature.preview_feature)
268-
in labs_data.data["preview_feature_status"]
215+
in labs_data.data.preview_feature_status
269216
)
270217
for preview_feature in labs_data.preview_features.values()
271218
if preview_feature.domain in loaded_components
@@ -325,12 +272,12 @@ async def websocket_update_preview_feature(
325272

326273
# Update storage (only store enabled features, remove if disabled)
327274
if enabled:
328-
labs_data.data["preview_feature_status"].add((domain, preview_feature))
275+
labs_data.data.preview_feature_status.add((domain, preview_feature))
329276
else:
330-
labs_data.data["preview_feature_status"].discard((domain, preview_feature))
277+
labs_data.data.preview_feature_status.discard((domain, preview_feature))
331278

332279
# Save changes immediately
333-
await labs_data.store.async_save(labs_data.data)
280+
await labs_data.store.async_save(labs_data.data.to_store_format())
334281

335282
# Fire event
336283
event_data: EventLabsUpdatedData = {

homeassistant/components/labs/const.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass, field
6-
from typing import TYPE_CHECKING, TypedDict
6+
from typing import TYPE_CHECKING, Self, TypedDict
77

88
from homeassistant.util.hass_dict import HassKey
99

@@ -62,14 +62,52 @@ def to_dict(self, enabled: bool) -> dict[str, str | bool | None]:
6262
}
6363

6464

65-
type LabsStoreData = dict[str, set[tuple[str, str]]]
65+
@dataclass(kw_only=True)
66+
class LabsStoreData:
67+
"""Storage data for Labs."""
68+
69+
preview_feature_status: set[tuple[str, str]]
70+
71+
@classmethod
72+
def from_store_format(cls, data: NativeLabsStoreData | None) -> Self:
73+
"""Initialize from storage format."""
74+
if data is None:
75+
return cls(preview_feature_status=set())
76+
return cls(
77+
preview_feature_status={
78+
(item["domain"], item["preview_feature"])
79+
for item in data["preview_feature_status"]
80+
}
81+
)
82+
83+
def to_store_format(self) -> NativeLabsStoreData:
84+
"""Convert to storage format."""
85+
return {
86+
"preview_feature_status": [
87+
{"domain": domain, "preview_feature": preview_feature}
88+
for domain, preview_feature in self.preview_feature_status
89+
]
90+
}
91+
92+
93+
class NativeLabsStoreData(TypedDict):
94+
"""Storage data for Labs."""
95+
96+
preview_feature_status: list[NativeLabsStoredFeature]
97+
98+
99+
class NativeLabsStoredFeature(TypedDict):
100+
"""A single preview feature entry in storage format."""
101+
102+
domain: str
103+
preview_feature: str
66104

67105

68106
@dataclass
69107
class LabsData:
70108
"""Storage class for Labs global data."""
71109

72-
store: Store[LabsStoreData]
110+
store: Store[NativeLabsStoreData]
73111
data: LabsStoreData
74112
preview_features: dict[str, LabPreviewFeature] = field(default_factory=dict)
75113

tests/components/labs/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,18 @@
11
"""Tests for the Home Assistant Labs integration."""
2+
3+
from typing import Any
4+
5+
from pytest_unordered import unordered
6+
7+
8+
def assert_stored_labs_data(
9+
hass_storage: dict[str, Any],
10+
expected_data: list[dict[str, str]],
11+
) -> None:
12+
"""Assert that the storage has the expected enabled preview features."""
13+
assert hass_storage["core.labs"] == {
14+
"version": 1,
15+
"minor_version": 1,
16+
"key": "core.labs",
17+
"data": {"preview_feature_status": unordered(expected_data)},
18+
}

tests/components/labs/test_init.py

Lines changed: 14 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@
99

1010
from homeassistant.components.labs import (
1111
EVENT_LABS_UPDATED,
12-
LabsStorage,
1312
async_is_preview_feature_enabled,
1413
async_listen,
1514
)
1615
from homeassistant.components.labs.const import DOMAIN, LABS_DATA, LabPreviewFeature
1716
from homeassistant.core import HomeAssistant
18-
from homeassistant.helpers.storage import Store
1917
from homeassistant.loader import Integration
2018
from homeassistant.setup import async_setup_component
2119

20+
from . import assert_stored_labs_data
21+
2222

2323
async def test_async_setup(hass: HomeAssistant) -> None:
2424
"""Test the Labs integration setup."""
@@ -91,7 +91,12 @@ async def test_async_is_preview_feature_enabled_when_disabled(
9191

9292

9393
@pytest.mark.parametrize(
94-
("features_to_store", "expected_enabled", "expected_cleaned"),
94+
(
95+
"features_to_store",
96+
"expected_enabled",
97+
"expected_cleaned",
98+
"expected_cleaned_store",
99+
),
95100
[
96101
# Single stale feature cleanup
97102
(
@@ -101,6 +106,7 @@ async def test_async_is_preview_feature_enabled_when_disabled(
101106
],
102107
[("kitchen_sink", "special_repair")],
103108
[("nonexistent_domain", "fake_feature")],
109+
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
104110
),
105111
# Multiple stale features cleanup
106112
(
@@ -116,12 +122,14 @@ async def test_async_is_preview_feature_enabled_when_disabled(
116122
("stale_domain_2", "another_old"),
117123
("stale_domain_3", "yet_another"),
118124
],
125+
[{"domain": "kitchen_sink", "preview_feature": "special_repair"}],
119126
),
120127
# All features cleaned (no integrations loaded)
121128
(
122129
[{"domain": "nonexistent", "preview_feature": "fake"}],
123130
[],
124131
[("nonexistent", "fake")],
132+
[],
125133
),
126134
],
127135
)
@@ -131,6 +139,7 @@ async def test_storage_cleanup_stale_features(
131139
features_to_store: list[dict[str, str]],
132140
expected_enabled: list[tuple[str, str]],
133141
expected_cleaned: list[tuple[str, str]],
142+
expected_cleaned_store: list[dict[str, str]],
134143
) -> None:
135144
"""Test that stale preview features are removed from storage on setup."""
136145
# Load kitchen_sink only if we expect any features to remain
@@ -155,6 +164,8 @@ async def test_storage_cleanup_stale_features(
155164
for domain, feature in expected_cleaned:
156165
assert not async_is_preview_feature_enabled(hass, domain, feature)
157166

167+
assert_stored_labs_data(hass_storage, expected_cleaned_store)
168+
158169

159170
@pytest.mark.parametrize(
160171
("domain", "preview_feature", "expected"),
@@ -191,37 +202,6 @@ async def test_async_is_preview_feature_enabled(
191202
assert result is expected
192203

193204

194-
async def test_multiple_setups_idempotent(hass: HomeAssistant) -> None:
195-
"""Test that calling async_setup multiple times is safe."""
196-
result1 = await async_setup_component(hass, DOMAIN, {})
197-
assert result1 is True
198-
199-
result2 = await async_setup_component(hass, DOMAIN, {})
200-
assert result2 is True
201-
202-
# Verify store is still accessible
203-
assert LABS_DATA in hass.data
204-
205-
206-
async def test_storage_load_missing_preview_feature_status_key(
207-
hass: HomeAssistant, hass_storage: dict[str, Any]
208-
) -> None:
209-
"""Test loading storage when preview_feature_status key is missing."""
210-
# Storage data without preview_feature_status key
211-
hass_storage["core.labs"] = {
212-
"version": 1,
213-
"minor_version": 1,
214-
"key": "core.labs",
215-
"data": {}, # Missing preview_feature_status
216-
}
217-
218-
assert await async_setup_component(hass, DOMAIN, {})
219-
await hass.async_block_till_done()
220-
221-
# Should initialize correctly - verify no feature is enabled
222-
assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair")
223-
224-
225205
async def test_preview_feature_full_key(hass: HomeAssistant) -> None:
226206
"""Test that preview feature full_key property returns correct format."""
227207
feature = LabPreviewFeature(
@@ -276,24 +256,6 @@ async def test_preview_feature_to_dict_with_no_urls(hass: HomeAssistant) -> None
276256
}
277257

278258

279-
async def test_storage_load_returns_none_when_no_file(
280-
hass: HomeAssistant,
281-
) -> None:
282-
"""Test storage load when no file exists (returns None)."""
283-
# Create a storage instance but don't write any data
284-
store = LabsStorage(hass, 1, "test_labs_none.json")
285-
286-
# Mock the parent Store's _async_load_data to return None
287-
# This simulates the edge case where Store._async_load_data returns None
288-
# This tests line 60: return None
289-
async def mock_load_none():
290-
return None
291-
292-
with patch.object(Store, "_async_load_data", new=mock_load_none):
293-
result = await store.async_load()
294-
assert result is None
295-
296-
297259
async def test_custom_integration_with_preview_features(
298260
hass: HomeAssistant,
299261
) -> None:

0 commit comments

Comments
 (0)