pytest-homeassistant-custom-component 0.13.283__tar.gz → 0.13.303__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_homeassistant_custom_component-0.13.283/src/pytest_homeassistant_custom_component.egg-info → pytest_homeassistant_custom_component-0.13.303}/PKG-INFO +7 -10
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/README.md +1 -3
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/common.py +16 -11
- pytest_homeassistant_custom_component-0.13.303/src/pytest_homeassistant_custom_component/components/__init__.py +399 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/components/recorder/common.py +117 -2
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/const.py +3 -3
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/ignore_uncaught_exceptions.py +9 -9
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/patch_time.py +25 -2
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/plugins.py +71 -39
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/test_util/aiohttp.py +2 -2
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/typing.py +1 -1
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303/src/pytest_homeassistant_custom_component.egg-info}/PKG-INFO +7 -10
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component.egg-info/requires.txt +5 -6
- pytest_homeassistant_custom_component-0.13.283/src/pytest_homeassistant_custom_component/components/__init__.py +0 -5
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/LICENSE +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/LICENSE_HA_CORE.md +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/setup.cfg +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/setup.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/__init__.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/asyncio_legacy.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/components/diagnostics/__init__.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/components/recorder/__init__.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/components/recorder/db_schema_0.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/patch_json.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/patch_recorder.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/syrupy.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/test_util/__init__.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/testing_config/__init__.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/testing_config/custom_components/__init__.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component/testing_config/custom_components/test_constant_deprecation/__init__.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component.egg-info/SOURCES.txt +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component.egg-info/dependency_links.txt +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component.egg-info/entry_points.txt +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/src/pytest_homeassistant_custom_component.egg-info/top_level.txt +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/tests/test_common.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/tests/test_config_flow.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/tests/test_diagnostics.py +0 -0
- {pytest_homeassistant_custom_component-0.13.283 → pytest_homeassistant_custom_component-0.13.303}/tests/test_sensor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-homeassistant-custom-component
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.303
|
|
4
4
|
Summary: Experimental package to automatically extract test plugins for Home Assistant custom components
|
|
5
5
|
Home-page: https://github.com/MatthewFlamm/pytest-homeassistant-custom-component
|
|
6
6
|
Author: Matthew Flamm
|
|
@@ -20,13 +20,12 @@ License-File: LICENSE_HA_CORE.md
|
|
|
20
20
|
Requires-Dist: sqlalchemy
|
|
21
21
|
Requires-Dist: coverage==7.10.6
|
|
22
22
|
Requires-Dist: freezegun==1.5.2
|
|
23
|
-
Requires-Dist: go2rtc-client==0.2.1
|
|
24
23
|
Requires-Dist: license-expression==30.4.3
|
|
25
24
|
Requires-Dist: mock-open==1.4.0
|
|
26
|
-
Requires-Dist: pydantic==2.
|
|
25
|
+
Requires-Dist: pydantic==2.12.2
|
|
27
26
|
Requires-Dist: pylint-per-file-ignores==1.4.0
|
|
28
27
|
Requires-Dist: pipdeptree==2.26.1
|
|
29
|
-
Requires-Dist: pytest-asyncio==1.
|
|
28
|
+
Requires-Dist: pytest-asyncio==1.3.0
|
|
30
29
|
Requires-Dist: pytest-aiohttp==1.1.0
|
|
31
30
|
Requires-Dist: pytest-cov==7.0.0
|
|
32
31
|
Requires-Dist: pytest-freezer==0.4.9
|
|
@@ -37,12 +36,12 @@ Requires-Dist: pytest-timeout==2.4.0
|
|
|
37
36
|
Requires-Dist: pytest-unordered==0.7.0
|
|
38
37
|
Requires-Dist: pytest-picked==0.5.1
|
|
39
38
|
Requires-Dist: pytest-xdist==3.8.0
|
|
40
|
-
Requires-Dist: pytest==
|
|
39
|
+
Requires-Dist: pytest==9.0.0
|
|
41
40
|
Requires-Dist: requests-mock==1.12.1
|
|
42
41
|
Requires-Dist: respx==0.22.0
|
|
43
|
-
Requires-Dist: syrupy==
|
|
42
|
+
Requires-Dist: syrupy==5.0.0
|
|
44
43
|
Requires-Dist: tqdm==4.67.1
|
|
45
|
-
Requires-Dist: homeassistant==
|
|
44
|
+
Requires-Dist: homeassistant==2026.1.0b3
|
|
46
45
|
Requires-Dist: SQLAlchemy==2.0.41
|
|
47
46
|
Requires-Dist: paho-mqtt==2.1.0
|
|
48
47
|
Requires-Dist: numpy==2.3.2
|
|
@@ -60,9 +59,7 @@ Dynamic: summary
|
|
|
60
59
|
|
|
61
60
|
# pytest-homeassistant-custom-component
|
|
62
61
|
|
|
63
|
-
](https://gitpod.io/#https://github.com/MatthewFlamm/pytest-homeassistant-custom-component)
|
|
62
|
+

|
|
66
63
|
|
|
67
64
|
Package to automatically extract testing plugins from Home Assistant for custom component testing.
|
|
68
65
|
The goal is to provide the same functionality as the tests in home-assistant/core.
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# pytest-homeassistant-custom-component
|
|
2
2
|
|
|
3
|
-
](https://gitpod.io/#https://github.com/MatthewFlamm/pytest-homeassistant-custom-component)
|
|
3
|
+

|
|
6
4
|
|
|
7
5
|
Package to automatically extract testing plugins from Home Assistant for custom component testing.
|
|
8
6
|
The goal is to provide the same functionality as the tests in home-assistant/core.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Test the helper method for writing .
|
|
2
|
+
Test the helper method for writing tests.
|
|
3
3
|
|
|
4
4
|
This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component.
|
|
5
5
|
"""
|
|
@@ -204,14 +204,14 @@ class StoreWithoutWriteLoad[_T: (Mapping[str, Any] | Sequence[Any])](storage.Sto
|
|
|
204
204
|
async def async_save(self, *args: Any, **kwargs: Any) -> None:
|
|
205
205
|
"""Save the data.
|
|
206
206
|
|
|
207
|
-
This function is mocked out in .
|
|
207
|
+
This function is mocked out in tests.
|
|
208
208
|
"""
|
|
209
209
|
|
|
210
210
|
@callback
|
|
211
211
|
def async_save_delay(self, *args: Any, **kwargs: Any) -> None:
|
|
212
212
|
"""Save data with an optional delay.
|
|
213
213
|
|
|
214
|
-
This function is mocked out in .
|
|
214
|
+
This function is mocked out in tests.
|
|
215
215
|
"""
|
|
216
216
|
|
|
217
217
|
|
|
@@ -942,6 +942,7 @@ class MockModule:
|
|
|
942
942
|
def mock_manifest(self):
|
|
943
943
|
"""Generate a mock manifest to represent this module."""
|
|
944
944
|
return {
|
|
945
|
+
"integration_type": "hub",
|
|
945
946
|
**loader.manifest_from_legacy_module(self.DOMAIN, self),
|
|
946
947
|
**(self._partial_manifest or {}),
|
|
947
948
|
}
|
|
@@ -1147,7 +1148,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
|
|
|
1147
1148
|
state: config_entries.ConfigEntryState,
|
|
1148
1149
|
reason: str | None = None,
|
|
1149
1150
|
) -> None:
|
|
1150
|
-
"""Mock the state of a config entry to be used in .
|
|
1151
|
+
"""Mock the state of a config entry to be used in tests.
|
|
1151
1152
|
|
|
1152
1153
|
Currently this is a wrapper around _async_set_state, but it may
|
|
1153
1154
|
change in the future.
|
|
@@ -1158,7 +1159,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
|
|
|
1158
1159
|
|
|
1159
1160
|
When in doubt, this helper should not be used in new code
|
|
1160
1161
|
and is only intended for backwards compatibility with existing
|
|
1161
|
-
.
|
|
1162
|
+
tests.
|
|
1162
1163
|
"""
|
|
1163
1164
|
self._async_set_state(hass, state, reason)
|
|
1164
1165
|
|
|
@@ -1538,7 +1539,7 @@ def mock_storage(data: dict[str, Any] | None = None) -> Generator[dict[str, Any]
|
|
|
1538
1539
|
return loaded
|
|
1539
1540
|
|
|
1540
1541
|
async def mock_write_data(
|
|
1541
|
-
store: storage.Store,
|
|
1542
|
+
store: storage.Store, data_to_write: dict[str, Any]
|
|
1542
1543
|
) -> None:
|
|
1543
1544
|
"""Mock version of write data."""
|
|
1544
1545
|
# To ensure that the data can be serialized
|
|
@@ -1615,12 +1616,16 @@ def mock_integration(
|
|
|
1615
1616
|
top_level_files: set[str] | None = None,
|
|
1616
1617
|
) -> loader.Integration:
|
|
1617
1618
|
"""Mock an integration."""
|
|
1618
|
-
|
|
1619
|
-
hass,
|
|
1619
|
+
path = (
|
|
1620
1620
|
f"{loader.PACKAGE_BUILTIN}.{module.DOMAIN}"
|
|
1621
1621
|
if built_in
|
|
1622
|
-
else f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{module.DOMAIN}"
|
|
1623
|
-
|
|
1622
|
+
else f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{module.DOMAIN}"
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
integration = loader.Integration(
|
|
1626
|
+
hass,
|
|
1627
|
+
path,
|
|
1628
|
+
pathlib.Path(path.replace(".", "/")),
|
|
1624
1629
|
module.mock_manifest(),
|
|
1625
1630
|
top_level_files,
|
|
1626
1631
|
)
|
|
@@ -1913,7 +1918,7 @@ def setup_test_component_platform(
|
|
|
1913
1918
|
from_config_entry: bool = False,
|
|
1914
1919
|
built_in: bool = True,
|
|
1915
1920
|
) -> MockPlatform:
|
|
1916
|
-
"""Mock a test component platform for ."""
|
|
1921
|
+
"""Mock a test component platform for tests."""
|
|
1917
1922
|
|
|
1918
1923
|
async def _async_setup_platform(
|
|
1919
1924
|
hass: HomeAssistant,
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The tests for components.
|
|
3
|
+
|
|
4
|
+
This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
import itertools
|
|
10
|
+
from typing import TypedDict
|
|
11
|
+
|
|
12
|
+
from homeassistant.const import (
|
|
13
|
+
ATTR_AREA_ID,
|
|
14
|
+
ATTR_DEVICE_ID,
|
|
15
|
+
ATTR_FLOOR_ID,
|
|
16
|
+
ATTR_LABEL_ID,
|
|
17
|
+
CONF_ENTITY_ID,
|
|
18
|
+
CONF_OPTIONS,
|
|
19
|
+
CONF_PLATFORM,
|
|
20
|
+
CONF_TARGET,
|
|
21
|
+
STATE_UNAVAILABLE,
|
|
22
|
+
STATE_UNKNOWN,
|
|
23
|
+
)
|
|
24
|
+
from homeassistant.core import HomeAssistant
|
|
25
|
+
from homeassistant.helpers import (
|
|
26
|
+
area_registry as ar,
|
|
27
|
+
device_registry as dr,
|
|
28
|
+
entity_registry as er,
|
|
29
|
+
floor_registry as fr,
|
|
30
|
+
label_registry as lr,
|
|
31
|
+
)
|
|
32
|
+
from homeassistant.setup import async_setup_component
|
|
33
|
+
|
|
34
|
+
from ..common import MockConfigEntry, mock_device_registry
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def target_entities(
|
|
38
|
+
hass: HomeAssistant, domain: str
|
|
39
|
+
) -> tuple[list[str], list[str]]:
|
|
40
|
+
"""Create multiple entities associated with different targets.
|
|
41
|
+
|
|
42
|
+
Returns a dict with the following keys:
|
|
43
|
+
- included: List of entity_ids meant to be targeted.
|
|
44
|
+
- excluded: List of entity_ids not meant to be targeted.
|
|
45
|
+
"""
|
|
46
|
+
config_entry = MockConfigEntry(domain="test")
|
|
47
|
+
config_entry.add_to_hass(hass)
|
|
48
|
+
|
|
49
|
+
floor_reg = fr.async_get(hass)
|
|
50
|
+
floor = floor_reg.async_get_floor_by_name("Test Floor") or floor_reg.async_create(
|
|
51
|
+
"Test Floor"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
area_reg = ar.async_get(hass)
|
|
55
|
+
area = area_reg.async_get_area_by_name("Test Area") or area_reg.async_create(
|
|
56
|
+
"Test Area", floor_id=floor.floor_id
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
label_reg = lr.async_get(hass)
|
|
60
|
+
label = label_reg.async_get_label_by_name("Test Label") or label_reg.async_create(
|
|
61
|
+
"Test Label"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
|
|
65
|
+
mock_device_registry(hass, {device.id: device})
|
|
66
|
+
|
|
67
|
+
entity_reg = er.async_get(hass)
|
|
68
|
+
# Entities associated with area
|
|
69
|
+
entity_area = entity_reg.async_get_or_create(
|
|
70
|
+
domain=domain,
|
|
71
|
+
platform="test",
|
|
72
|
+
unique_id=f"{domain}_area",
|
|
73
|
+
suggested_object_id=f"area_{domain}",
|
|
74
|
+
)
|
|
75
|
+
entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id)
|
|
76
|
+
entity_area_excluded = entity_reg.async_get_or_create(
|
|
77
|
+
domain=domain,
|
|
78
|
+
platform="test",
|
|
79
|
+
unique_id=f"{domain}_area_excluded",
|
|
80
|
+
suggested_object_id=f"area_{domain}_excluded",
|
|
81
|
+
)
|
|
82
|
+
entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id)
|
|
83
|
+
|
|
84
|
+
# Entities associated with device
|
|
85
|
+
entity_reg.async_get_or_create(
|
|
86
|
+
domain=domain,
|
|
87
|
+
platform="test",
|
|
88
|
+
unique_id=f"{domain}_device",
|
|
89
|
+
suggested_object_id=f"device_{domain}",
|
|
90
|
+
device_id=device.id,
|
|
91
|
+
)
|
|
92
|
+
entity_reg.async_get_or_create(
|
|
93
|
+
domain=domain,
|
|
94
|
+
platform="test",
|
|
95
|
+
unique_id=f"{domain}_device_excluded",
|
|
96
|
+
suggested_object_id=f"device_{domain}_excluded",
|
|
97
|
+
device_id=device.id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Entities associated with label
|
|
101
|
+
entity_label = entity_reg.async_get_or_create(
|
|
102
|
+
domain=domain,
|
|
103
|
+
platform="test",
|
|
104
|
+
unique_id=f"{domain}_label",
|
|
105
|
+
suggested_object_id=f"label_{domain}",
|
|
106
|
+
)
|
|
107
|
+
entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id})
|
|
108
|
+
entity_label_excluded = entity_reg.async_get_or_create(
|
|
109
|
+
domain=domain,
|
|
110
|
+
platform="test",
|
|
111
|
+
unique_id=f"{domain}_label_excluded",
|
|
112
|
+
suggested_object_id=f"label_{domain}_excluded",
|
|
113
|
+
)
|
|
114
|
+
entity_reg.async_update_entity(
|
|
115
|
+
entity_label_excluded.entity_id, labels={label.label_id}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Return all available entities
|
|
119
|
+
return {
|
|
120
|
+
"included": [
|
|
121
|
+
f"{domain}.standalone_{domain}",
|
|
122
|
+
f"{domain}.label_{domain}",
|
|
123
|
+
f"{domain}.area_{domain}",
|
|
124
|
+
f"{domain}.device_{domain}",
|
|
125
|
+
],
|
|
126
|
+
"excluded": [
|
|
127
|
+
f"{domain}.standalone_{domain}_excluded",
|
|
128
|
+
f"{domain}.label_{domain}_excluded",
|
|
129
|
+
f"{domain}.area_{domain}_excluded",
|
|
130
|
+
f"{domain}.device_{domain}_excluded",
|
|
131
|
+
],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
|
|
136
|
+
"""Parametrize target entities for different target types.
|
|
137
|
+
|
|
138
|
+
Meant to be used with target_entities.
|
|
139
|
+
"""
|
|
140
|
+
return [
|
|
141
|
+
(
|
|
142
|
+
{CONF_ENTITY_ID: f"{domain}.standalone_{domain}"},
|
|
143
|
+
f"{domain}.standalone_{domain}",
|
|
144
|
+
1,
|
|
145
|
+
),
|
|
146
|
+
({ATTR_LABEL_ID: "test_label"}, f"{domain}.label_{domain}", 2),
|
|
147
|
+
({ATTR_AREA_ID: "test_area"}, f"{domain}.area_{domain}", 2),
|
|
148
|
+
({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.area_{domain}", 2),
|
|
149
|
+
({ATTR_LABEL_ID: "test_label"}, f"{domain}.device_{domain}", 2),
|
|
150
|
+
({ATTR_AREA_ID: "test_area"}, f"{domain}.device_{domain}", 2),
|
|
151
|
+
({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.device_{domain}", 2),
|
|
152
|
+
({ATTR_DEVICE_ID: "test_device"}, f"{domain}.device_{domain}", 1),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _StateDescription(TypedDict):
|
|
157
|
+
"""Test state and expected service call count."""
|
|
158
|
+
|
|
159
|
+
state: str | None
|
|
160
|
+
attributes: dict
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class StateDescription(TypedDict):
|
|
164
|
+
"""Test state and expected service call count."""
|
|
165
|
+
|
|
166
|
+
included: _StateDescription
|
|
167
|
+
excluded: _StateDescription
|
|
168
|
+
count: int
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parametrize_trigger_states(
|
|
172
|
+
*,
|
|
173
|
+
trigger: str,
|
|
174
|
+
target_states: list[str | None | tuple[str | None, dict]],
|
|
175
|
+
other_states: list[str | None | tuple[str | None, dict]],
|
|
176
|
+
additional_attributes: dict | None = None,
|
|
177
|
+
trigger_from_none: bool = True,
|
|
178
|
+
retrigger_on_target_state: bool = False,
|
|
179
|
+
) -> list[tuple[str, list[StateDescription]]]:
|
|
180
|
+
"""Parametrize states and expected service call counts.
|
|
181
|
+
|
|
182
|
+
The target_states and other_states iterables are either iterables of
|
|
183
|
+
states or iterables of (state, attributes) tuples.
|
|
184
|
+
|
|
185
|
+
Set `trigger_from_none` to False if the trigger is not expected to fire
|
|
186
|
+
when the initial state is None.
|
|
187
|
+
|
|
188
|
+
Set `retrigger_on_target_state` to True if the trigger is expected to fire
|
|
189
|
+
when the state changes to another target state.
|
|
190
|
+
|
|
191
|
+
Returns a list of tuples with (trigger, list of states),
|
|
192
|
+
where states is a list of StateDescription dicts.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
additional_attributes = additional_attributes or {}
|
|
196
|
+
|
|
197
|
+
def state_with_attributes(
|
|
198
|
+
state: str | None | tuple[str | None, dict], count: int
|
|
199
|
+
) -> dict:
|
|
200
|
+
"""Return (state, attributes) dict."""
|
|
201
|
+
if isinstance(state, str) or state is None:
|
|
202
|
+
return {
|
|
203
|
+
"included": {
|
|
204
|
+
"state": state,
|
|
205
|
+
"attributes": additional_attributes,
|
|
206
|
+
},
|
|
207
|
+
"excluded": {
|
|
208
|
+
"state": state,
|
|
209
|
+
"attributes": {},
|
|
210
|
+
},
|
|
211
|
+
"count": count,
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
"included": {
|
|
215
|
+
"state": state[0],
|
|
216
|
+
"attributes": state[1] | additional_attributes,
|
|
217
|
+
},
|
|
218
|
+
"excluded": {
|
|
219
|
+
"state": state[0],
|
|
220
|
+
"attributes": state[1],
|
|
221
|
+
},
|
|
222
|
+
"count": count,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
tests = [
|
|
226
|
+
# Initial state None
|
|
227
|
+
(
|
|
228
|
+
trigger,
|
|
229
|
+
list(
|
|
230
|
+
itertools.chain.from_iterable(
|
|
231
|
+
(
|
|
232
|
+
state_with_attributes(None, 0),
|
|
233
|
+
state_with_attributes(target_state, 0),
|
|
234
|
+
state_with_attributes(other_state, 0),
|
|
235
|
+
state_with_attributes(
|
|
236
|
+
target_state, 1 if trigger_from_none else 0
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
for target_state in target_states
|
|
240
|
+
for other_state in other_states
|
|
241
|
+
)
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
# Initial state different from target state
|
|
245
|
+
(
|
|
246
|
+
trigger,
|
|
247
|
+
# other_state,
|
|
248
|
+
list(
|
|
249
|
+
itertools.chain.from_iterable(
|
|
250
|
+
(
|
|
251
|
+
state_with_attributes(other_state, 0),
|
|
252
|
+
state_with_attributes(target_state, 1),
|
|
253
|
+
state_with_attributes(other_state, 0),
|
|
254
|
+
state_with_attributes(target_state, 1),
|
|
255
|
+
)
|
|
256
|
+
for target_state in target_states
|
|
257
|
+
for other_state in other_states
|
|
258
|
+
)
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
# Initial state same as target state
|
|
262
|
+
(
|
|
263
|
+
trigger,
|
|
264
|
+
list(
|
|
265
|
+
itertools.chain.from_iterable(
|
|
266
|
+
(
|
|
267
|
+
state_with_attributes(target_state, 0),
|
|
268
|
+
state_with_attributes(target_state, 0),
|
|
269
|
+
state_with_attributes(other_state, 0),
|
|
270
|
+
state_with_attributes(target_state, 1),
|
|
271
|
+
# Repeat target state to test retriggering
|
|
272
|
+
state_with_attributes(target_state, 0),
|
|
273
|
+
state_with_attributes(STATE_UNAVAILABLE, 0),
|
|
274
|
+
)
|
|
275
|
+
for target_state in target_states
|
|
276
|
+
for other_state in other_states
|
|
277
|
+
)
|
|
278
|
+
),
|
|
279
|
+
),
|
|
280
|
+
# Initial state unavailable / unknown
|
|
281
|
+
(
|
|
282
|
+
trigger,
|
|
283
|
+
list(
|
|
284
|
+
itertools.chain.from_iterable(
|
|
285
|
+
(
|
|
286
|
+
state_with_attributes(STATE_UNAVAILABLE, 0),
|
|
287
|
+
state_with_attributes(target_state, 0),
|
|
288
|
+
state_with_attributes(other_state, 0),
|
|
289
|
+
state_with_attributes(target_state, 1),
|
|
290
|
+
)
|
|
291
|
+
for target_state in target_states
|
|
292
|
+
for other_state in other_states
|
|
293
|
+
)
|
|
294
|
+
),
|
|
295
|
+
),
|
|
296
|
+
(
|
|
297
|
+
trigger,
|
|
298
|
+
list(
|
|
299
|
+
itertools.chain.from_iterable(
|
|
300
|
+
(
|
|
301
|
+
state_with_attributes(STATE_UNKNOWN, 0),
|
|
302
|
+
state_with_attributes(target_state, 0),
|
|
303
|
+
state_with_attributes(other_state, 0),
|
|
304
|
+
state_with_attributes(target_state, 1),
|
|
305
|
+
)
|
|
306
|
+
for target_state in target_states
|
|
307
|
+
for other_state in other_states
|
|
308
|
+
)
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
if len(target_states) > 1:
|
|
314
|
+
# If more than one target state, test state change between target states
|
|
315
|
+
tests.append(
|
|
316
|
+
(
|
|
317
|
+
trigger,
|
|
318
|
+
list(
|
|
319
|
+
itertools.chain.from_iterable(
|
|
320
|
+
(
|
|
321
|
+
state_with_attributes(target_states[idx - 1], 0),
|
|
322
|
+
state_with_attributes(
|
|
323
|
+
target_state, 1 if retrigger_on_target_state else 0
|
|
324
|
+
),
|
|
325
|
+
state_with_attributes(other_state, 0),
|
|
326
|
+
state_with_attributes(target_states[idx - 1], 1),
|
|
327
|
+
state_with_attributes(
|
|
328
|
+
target_state, 1 if retrigger_on_target_state else 0
|
|
329
|
+
),
|
|
330
|
+
state_with_attributes(STATE_UNAVAILABLE, 0),
|
|
331
|
+
)
|
|
332
|
+
for idx, target_state in enumerate(target_states[1:], start=1)
|
|
333
|
+
for other_state in other_states
|
|
334
|
+
)
|
|
335
|
+
),
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return tests
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def arm_trigger(
|
|
343
|
+
hass: HomeAssistant,
|
|
344
|
+
trigger: str,
|
|
345
|
+
trigger_options: dict | None,
|
|
346
|
+
trigger_target: dict,
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Arm the specified trigger, call service test.automation when it triggers."""
|
|
349
|
+
|
|
350
|
+
# Local include to avoid importing the automation component unnecessarily
|
|
351
|
+
from homeassistant.components import automation # noqa: PLC0415
|
|
352
|
+
|
|
353
|
+
options = {CONF_OPTIONS: {**trigger_options}} if trigger_options is not None else {}
|
|
354
|
+
|
|
355
|
+
await async_setup_component(
|
|
356
|
+
hass,
|
|
357
|
+
automation.DOMAIN,
|
|
358
|
+
{
|
|
359
|
+
automation.DOMAIN: {
|
|
360
|
+
"trigger": {
|
|
361
|
+
CONF_PLATFORM: trigger,
|
|
362
|
+
CONF_TARGET: {**trigger_target},
|
|
363
|
+
}
|
|
364
|
+
| options,
|
|
365
|
+
"action": {
|
|
366
|
+
"service": "test.automation",
|
|
367
|
+
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def set_or_remove_state(
|
|
375
|
+
hass: HomeAssistant,
|
|
376
|
+
entity_id: str,
|
|
377
|
+
state: StateDescription,
|
|
378
|
+
) -> None:
|
|
379
|
+
"""Set or remove the state of an entity."""
|
|
380
|
+
if state["state"] is None:
|
|
381
|
+
hass.states.async_remove(entity_id)
|
|
382
|
+
else:
|
|
383
|
+
hass.states.async_set(
|
|
384
|
+
entity_id, state["state"], state["attributes"], force_update=True
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def other_states(state: StrEnum | Iterable[StrEnum]) -> list[str]:
|
|
389
|
+
"""Return a sorted list with all states except the specified one."""
|
|
390
|
+
if isinstance(state, StrEnum):
|
|
391
|
+
excluded_values = {state.value}
|
|
392
|
+
enum_class = state.__class__
|
|
393
|
+
else:
|
|
394
|
+
if len(state) == 0:
|
|
395
|
+
raise ValueError("state iterable must not be empty")
|
|
396
|
+
excluded_values = {s.value for s in state}
|
|
397
|
+
enum_class = list(state)[0].__class__
|
|
398
|
+
|
|
399
|
+
return sorted({s.value for s in enum_class} - excluded_values)
|
|
@@ -15,10 +15,12 @@ from functools import partial
|
|
|
15
15
|
import importlib
|
|
16
16
|
import sys
|
|
17
17
|
import time
|
|
18
|
+
from types import ModuleType
|
|
18
19
|
from typing import Any, Literal, cast
|
|
19
20
|
from unittest.mock import MagicMock, patch, sentinel
|
|
20
21
|
|
|
21
22
|
from freezegun import freeze_time
|
|
23
|
+
import pytest
|
|
22
24
|
from sqlalchemy import create_engine, event as sqlalchemy_event
|
|
23
25
|
from sqlalchemy.orm.session import Session
|
|
24
26
|
|
|
@@ -32,18 +34,25 @@ from homeassistant.components.recorder import (
|
|
|
32
34
|
statistics,
|
|
33
35
|
)
|
|
34
36
|
from homeassistant.components.recorder.db_schema import (
|
|
37
|
+
EventData,
|
|
35
38
|
Events,
|
|
36
39
|
EventTypes,
|
|
37
40
|
RecorderRuns,
|
|
41
|
+
StateAttributes,
|
|
38
42
|
States,
|
|
39
43
|
StatesMeta,
|
|
40
44
|
)
|
|
45
|
+
from homeassistant.components.recorder.models import (
|
|
46
|
+
bytes_to_ulid_or_none,
|
|
47
|
+
bytes_to_uuid_hex_or_none,
|
|
48
|
+
)
|
|
41
49
|
from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask
|
|
42
50
|
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
|
43
51
|
from homeassistant.const import DEGREE, UnitOfTemperature
|
|
44
52
|
from homeassistant.core import Event, HomeAssistant, State
|
|
45
53
|
from homeassistant.helpers import recorder as recorder_helper
|
|
46
54
|
from homeassistant.util import dt as dt_util
|
|
55
|
+
from homeassistant.util.json import json_loads, json_loads_object
|
|
47
56
|
|
|
48
57
|
from . import db_schema_0
|
|
49
58
|
|
|
@@ -200,7 +209,7 @@ def statistics_during_period(
|
|
|
200
209
|
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]]
|
|
201
210
|
| None = None,
|
|
202
211
|
) -> dict[str, list[dict[str, Any]]]:
|
|
203
|
-
"""Call statistics_during_period with defaults for simpler
|
|
212
|
+
"""Call statistics_during_period with defaults for simpler tests."""
|
|
204
213
|
if statistic_ids is not None and not isinstance(statistic_ids, set):
|
|
205
214
|
statistic_ids = set(statistic_ids)
|
|
206
215
|
if types is None:
|
|
@@ -453,7 +462,14 @@ def create_engine_test_for_schema_version_postfix(
|
|
|
453
462
|
|
|
454
463
|
def get_schema_module_path(schema_version_postfix: str) -> str:
|
|
455
464
|
"""Return the path to the schema module."""
|
|
456
|
-
return f"
|
|
465
|
+
return f"tests.components.recorder.db_schema_{schema_version_postfix}"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def get_patched_live_version(old_db_schema: ModuleType) -> int:
|
|
469
|
+
"""Return the patched live migration version."""
|
|
470
|
+
return min(
|
|
471
|
+
migration.LIVE_MIGRATION_MIN_SCHEMA_VERSION, old_db_schema.SCHEMA_VERSION
|
|
472
|
+
)
|
|
457
473
|
|
|
458
474
|
|
|
459
475
|
@contextmanager
|
|
@@ -466,6 +482,11 @@ def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[
|
|
|
466
482
|
with (
|
|
467
483
|
patch.object(recorder, "db_schema", old_db_schema),
|
|
468
484
|
patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION),
|
|
485
|
+
patch.object(
|
|
486
|
+
migration,
|
|
487
|
+
"LIVE_MIGRATION_MIN_SCHEMA_VERSION",
|
|
488
|
+
get_patched_live_version(old_db_schema),
|
|
489
|
+
),
|
|
469
490
|
patch.object(migration, "non_live_data_migration_needed", return_value=False),
|
|
470
491
|
patch.object(core, "StatesMeta", old_db_schema.StatesMeta),
|
|
471
492
|
patch.object(core, "EventTypes", old_db_schema.EventTypes),
|
|
@@ -496,3 +517,97 @@ async def async_attach_db_engine(hass: HomeAssistant) -> None:
|
|
|
496
517
|
)
|
|
497
518
|
|
|
498
519
|
await instance.async_add_executor_job(_mock_setup_recorder_connection)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
EVENT_ORIGIN_ORDER = [ha.EventOrigin.local, ha.EventOrigin.remote]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def db_event_to_native(event: Events, validate_entity_id: bool = True) -> Event | None:
|
|
526
|
+
"""Convert to a native HA Event."""
|
|
527
|
+
context = ha.Context(
|
|
528
|
+
id=bytes_to_ulid_or_none(event.context_id_bin),
|
|
529
|
+
user_id=bytes_to_uuid_hex_or_none(event.context_user_id_bin),
|
|
530
|
+
parent_id=bytes_to_ulid_or_none(event.context_parent_id_bin),
|
|
531
|
+
)
|
|
532
|
+
return Event(
|
|
533
|
+
event.event_type or "",
|
|
534
|
+
json_loads_object(event.event_data) if event.event_data else {},
|
|
535
|
+
ha.EventOrigin(event.origin)
|
|
536
|
+
if event.origin
|
|
537
|
+
else EVENT_ORIGIN_ORDER[event.origin_idx or 0],
|
|
538
|
+
event.time_fired_ts or 0,
|
|
539
|
+
context=context,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def db_event_data_to_native(event_data: EventData) -> dict[str, Any]:
|
|
544
|
+
"""Convert to an event data dictionary."""
|
|
545
|
+
shared_data = event_data.shared_data
|
|
546
|
+
if shared_data is None:
|
|
547
|
+
return {}
|
|
548
|
+
return cast(dict[str, Any], json_loads(shared_data))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def db_state_to_native(state: States, validate_entity_id: bool = True) -> State | None:
|
|
552
|
+
"""Convert to an HA state object."""
|
|
553
|
+
context = ha.Context(
|
|
554
|
+
id=bytes_to_ulid_or_none(state.context_id_bin),
|
|
555
|
+
user_id=bytes_to_uuid_hex_or_none(state.context_user_id_bin),
|
|
556
|
+
parent_id=bytes_to_ulid_or_none(state.context_parent_id_bin),
|
|
557
|
+
)
|
|
558
|
+
attrs = json_loads_object(state.attributes) if state.attributes else {}
|
|
559
|
+
last_updated = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
|
|
560
|
+
if state.last_changed_ts is None or state.last_changed_ts == state.last_updated_ts:
|
|
561
|
+
last_changed = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
|
|
562
|
+
else:
|
|
563
|
+
last_changed = dt_util.utc_from_timestamp(state.last_changed_ts or 0)
|
|
564
|
+
if (
|
|
565
|
+
state.last_reported_ts is None
|
|
566
|
+
or state.last_reported_ts == state.last_updated_ts
|
|
567
|
+
):
|
|
568
|
+
last_reported = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
|
|
569
|
+
else:
|
|
570
|
+
last_reported = dt_util.utc_from_timestamp(state.last_reported_ts or 0)
|
|
571
|
+
return State(
|
|
572
|
+
state.entity_id or "",
|
|
573
|
+
state.state, # type: ignore[arg-type]
|
|
574
|
+
# Join the state_attributes table on attributes_id to get the attributes
|
|
575
|
+
# for newer states
|
|
576
|
+
attrs,
|
|
577
|
+
last_changed=last_changed,
|
|
578
|
+
last_reported=last_reported,
|
|
579
|
+
last_updated=last_updated,
|
|
580
|
+
context=context,
|
|
581
|
+
validate_entity_id=validate_entity_id,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def db_state_attributes_to_native(state_attrs: StateAttributes) -> dict[str, Any]:
|
|
586
|
+
"""Convert to a state attributes dictionary."""
|
|
587
|
+
shared_attrs = state_attrs.shared_attrs
|
|
588
|
+
if shared_attrs is None:
|
|
589
|
+
return {}
|
|
590
|
+
return cast(dict[str, Any], json_loads(shared_attrs))
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
async def async_drop_index(
|
|
594
|
+
recorder: Recorder, table: str, index: str, caplog: pytest.LogCaptureFixture
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Drop an index from the database.
|
|
597
|
+
|
|
598
|
+
migration._drop_index does not return or raise, so we verify the result
|
|
599
|
+
by checking the log for success or failure messages.
|
|
600
|
+
"""
|
|
601
|
+
|
|
602
|
+
finish_msg = f"Finished dropping index `{index}` from table `{table}`"
|
|
603
|
+
fail_msg = f"Failed to drop index `{index}` from table `{table}`"
|
|
604
|
+
|
|
605
|
+
count_finish = caplog.text.count(finish_msg)
|
|
606
|
+
count_fail = caplog.text.count(fail_msg)
|
|
607
|
+
|
|
608
|
+
await recorder.async_add_executor_job(
|
|
609
|
+
migration._drop_index, recorder.get_session, table, index
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
assert caplog.text.count(finish_msg) == count_finish + 1
|
|
613
|
+
assert caplog.text.count(fail_msg) == count_fail
|
|
@@ -4,9 +4,9 @@ Constants used by Home Assistant components.
|
|
|
4
4
|
This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component.
|
|
5
5
|
"""
|
|
6
6
|
from typing import TYPE_CHECKING, Final
|
|
7
|
-
MAJOR_VERSION: Final =
|
|
8
|
-
MINOR_VERSION: Final =
|
|
9
|
-
PATCH_VERSION: Final = "
|
|
7
|
+
MAJOR_VERSION: Final = 2026
|
|
8
|
+
MINOR_VERSION: Final = 1
|
|
9
|
+
PATCH_VERSION: Final = "0b3"
|
|
10
10
|
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
|
11
11
|
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
|
12
12
|
CONF_API_VERSION: Final = "api_version"
|
|
@@ -8,25 +8,25 @@ IGNORE_UNCAUGHT_EXCEPTIONS = [
|
|
|
8
8
|
(
|
|
9
9
|
# This test explicitly throws an uncaught exception
|
|
10
10
|
# and should not be removed.
|
|
11
|
-
".test_runner",
|
|
11
|
+
"tests.test_runner",
|
|
12
12
|
"test_unhandled_exception_traceback",
|
|
13
13
|
),
|
|
14
14
|
(
|
|
15
15
|
# This test explicitly throws an uncaught exception
|
|
16
16
|
# and should not be removed.
|
|
17
|
-
".helpers.test_event",
|
|
17
|
+
"tests.helpers.test_event",
|
|
18
18
|
"test_track_point_in_time_repr",
|
|
19
19
|
),
|
|
20
20
|
(
|
|
21
21
|
# This test explicitly throws an uncaught exception
|
|
22
22
|
# and should not be removed.
|
|
23
|
-
".test_config_entries",
|
|
23
|
+
"tests.test_config_entries",
|
|
24
24
|
"test_config_entry_unloaded_during_platform_setups",
|
|
25
25
|
),
|
|
26
26
|
(
|
|
27
27
|
# This test explicitly throws an uncaught exception
|
|
28
28
|
# and should not be removed.
|
|
29
|
-
".test_config_entries",
|
|
29
|
+
"tests.test_config_entries",
|
|
30
30
|
"test_config_entry_unloaded_during_platform_setup",
|
|
31
31
|
),
|
|
32
32
|
(
|
|
@@ -34,17 +34,17 @@ IGNORE_UNCAUGHT_EXCEPTIONS = [
|
|
|
34
34
|
"test_homeassistant_bridge_fan_setup",
|
|
35
35
|
),
|
|
36
36
|
(
|
|
37
|
-
".components.owntracks.test_device_tracker",
|
|
37
|
+
"tests.components.owntracks.test_device_tracker",
|
|
38
38
|
"test_mobile_multiple_async_enter_exit",
|
|
39
39
|
),
|
|
40
40
|
(
|
|
41
|
-
".components.smartthings.test_init",
|
|
41
|
+
"tests.components.smartthings.test_init",
|
|
42
42
|
"test_event_handler_dispatches_updated_devices",
|
|
43
43
|
),
|
|
44
44
|
(
|
|
45
|
-
".components.unifi.test_controller",
|
|
45
|
+
"tests.components.unifi.test_controller",
|
|
46
46
|
"test_wireless_client_event_calls_update_wireless_devices",
|
|
47
47
|
),
|
|
48
|
-
(".components.iaqualink.test_config_flow", "test_with_invalid_credentials"),
|
|
49
|
-
(".components.iaqualink.test_config_flow", "test_with_existing_config"),
|
|
48
|
+
("tests.components.iaqualink.test_config_flow", "test_with_invalid_credentials"),
|
|
49
|
+
("tests.components.iaqualink.test_config_flow", "test_with_existing_config"),
|
|
50
50
|
]
|
|
@@ -30,8 +30,31 @@ def ha_datetime_to_fakedatetime(datetime) -> freezegun.api.FakeDatetime: # type
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
class
|
|
34
|
-
"""Modified to
|
|
33
|
+
class HAFakeDateMeta(freezegun.api.FakeDateMeta):
|
|
34
|
+
"""Modified to override the string representation."""
|
|
35
|
+
|
|
36
|
+
def __str__(cls) -> str: # noqa: N805 (ruff doesn't know this is a metaclass)
|
|
37
|
+
"""Return the string representation of the class."""
|
|
38
|
+
return "<class 'datetime.date'>"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HAFakeDate(freezegun.api.FakeDate, metaclass=HAFakeDateMeta): # type: ignore[name-defined]
|
|
42
|
+
"""Modified to improve class str."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HAFakeDatetimeMeta(freezegun.api.FakeDatetimeMeta):
|
|
46
|
+
"""Modified to override the string representation."""
|
|
47
|
+
|
|
48
|
+
def __str__(cls) -> str: # noqa: N805 (ruff doesn't know this is a metaclass)
|
|
49
|
+
"""Return the string representation of the class."""
|
|
50
|
+
return "<class 'datetime.datetime'>"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HAFakeDatetime(freezegun.api.FakeDatetime, metaclass=HAFakeDatetimeMeta): # type: ignore[name-defined]
|
|
54
|
+
"""Modified to include basic fold support and improve class str.
|
|
55
|
+
|
|
56
|
+
Fold support submitted to upstream in https://github.com/spulec/freezegun/pull/424.
|
|
57
|
+
"""
|
|
35
58
|
|
|
36
59
|
@classmethod
|
|
37
60
|
def now(cls, tz=None):
|
|
@@ -131,7 +131,7 @@ if TYPE_CHECKING:
|
|
|
131
131
|
from homeassistant.components import recorder
|
|
132
132
|
|
|
133
133
|
|
|
134
|
-
pytest.register_assert_rewrite(".common")
|
|
134
|
+
pytest.register_assert_rewrite("tests.common")
|
|
135
135
|
|
|
136
136
|
from .common import ( # noqa: E402, isort:skip
|
|
137
137
|
CLIENT_ID,
|
|
@@ -163,6 +163,7 @@ asyncio.set_event_loop_policy = lambda policy: None
|
|
|
163
163
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
164
164
|
"""Register custom pytest options."""
|
|
165
165
|
parser.addoption("--dburl", action="store", default="sqlite://")
|
|
166
|
+
parser.addoption("--drop-existing-db", action="store_const", const=True)
|
|
166
167
|
|
|
167
168
|
|
|
168
169
|
def pytest_configure(config: pytest.Config) -> None:
|
|
@@ -190,11 +191,13 @@ def pytest_runtest_setup() -> None:
|
|
|
190
191
|
destinations will be allowed.
|
|
191
192
|
|
|
192
193
|
freezegun:
|
|
193
|
-
Modified to include https://github.com/spulec/freezegun/pull/424
|
|
194
|
+
Modified to include https://github.com/spulec/freezegun/pull/424 and improve class str.
|
|
194
195
|
"""
|
|
195
196
|
pytest_socket.socket_allow_hosts(["127.0.0.1"])
|
|
196
197
|
pytest_socket.disable_socket(allow_unix_socket=True)
|
|
197
198
|
|
|
199
|
+
freezegun.api.FakeDate = patch_time.HAFakeDate # type: ignore[attr-defined]
|
|
200
|
+
|
|
198
201
|
freezegun.api.datetime_to_fakedatetime = patch_time.ha_datetime_to_fakedatetime # type: ignore[attr-defined]
|
|
199
202
|
freezegun.api.FakeDatetime = patch_time.HAFakeDatetime # type: ignore[attr-defined]
|
|
200
203
|
|
|
@@ -1271,9 +1274,11 @@ def evict_faked_translations(translations_once) -> Generator[_patch]:
|
|
|
1271
1274
|
component_paths = components.__path__
|
|
1272
1275
|
|
|
1273
1276
|
for call in mock_component_strings.mock_calls:
|
|
1277
|
+
_components: set[str] = call.args[2]
|
|
1274
1278
|
integrations: dict[str, loader.Integration] = call.args[3]
|
|
1275
|
-
for domain
|
|
1276
|
-
|
|
1279
|
+
for domain in _components:
|
|
1280
|
+
# If the integration exists, don't evict from cache
|
|
1281
|
+
if (integration := integrations.get(domain)) and any(
|
|
1277
1282
|
pathlib.Path(f"{component_path}/{domain}") == integration.file_path
|
|
1278
1283
|
for component_path in component_paths
|
|
1279
1284
|
):
|
|
@@ -1475,7 +1480,7 @@ def persistent_database() -> bool:
|
|
|
1475
1480
|
When using sqlite, this uses on disk database instead of in memory database.
|
|
1476
1481
|
This does nothing when using mysql or postgresql.
|
|
1477
1482
|
|
|
1478
|
-
Note that the database is always destroyed in between .
|
|
1483
|
+
Note that the database is always destroyed in between tests.
|
|
1479
1484
|
|
|
1480
1485
|
To use a persistent database, tests can be marked with:
|
|
1481
1486
|
@pytest.mark.parametrize("persistent_database", [True])
|
|
@@ -1494,44 +1499,58 @@ def recorder_db_url(
|
|
|
1494
1499
|
assert not hass_fixture_setup
|
|
1495
1500
|
|
|
1496
1501
|
db_url = cast(str, pytestconfig.getoption("dburl"))
|
|
1502
|
+
drop_existing_db = pytestconfig.getoption("drop_existing_db")
|
|
1503
|
+
|
|
1504
|
+
def drop_db() -> None:
|
|
1505
|
+
import sqlalchemy as sa # noqa: PLC0415
|
|
1506
|
+
import sqlalchemy_utils # noqa: PLC0415
|
|
1507
|
+
|
|
1508
|
+
if db_url.startswith("mysql://"):
|
|
1509
|
+
made_url = sa.make_url(db_url)
|
|
1510
|
+
db = made_url.database
|
|
1511
|
+
engine = sa.create_engine(db_url)
|
|
1512
|
+
# Check for any open connections to the database before dropping it
|
|
1513
|
+
# to ensure that InnoDB does not deadlock.
|
|
1514
|
+
with engine.begin() as connection:
|
|
1515
|
+
query = sa.text(
|
|
1516
|
+
"select id FROM information_schema.processlist WHERE db=:db and id != CONNECTION_ID()"
|
|
1517
|
+
)
|
|
1518
|
+
rows = connection.execute(query, parameters={"db": db}).fetchall()
|
|
1519
|
+
if rows:
|
|
1520
|
+
raise RuntimeError(
|
|
1521
|
+
f"Unable to drop database {db} because it is in use by {rows}"
|
|
1522
|
+
)
|
|
1523
|
+
engine.dispose()
|
|
1524
|
+
sqlalchemy_utils.drop_database(db_url)
|
|
1525
|
+
elif db_url.startswith("postgresql://"):
|
|
1526
|
+
sqlalchemy_utils.drop_database(db_url)
|
|
1527
|
+
|
|
1497
1528
|
if db_url == "sqlite://" and persistent_database:
|
|
1498
1529
|
tmp_path = tmp_path_factory.mktemp("recorder")
|
|
1499
1530
|
db_url = "sqlite:///" + str(tmp_path / "pytest.db")
|
|
1500
|
-
elif db_url.startswith("mysql://"):
|
|
1531
|
+
elif db_url.startswith(("mysql://", "postgresql://")):
|
|
1501
1532
|
import sqlalchemy_utils # noqa: PLC0415
|
|
1502
1533
|
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1534
|
+
if drop_existing_db and sqlalchemy_utils.database_exists(db_url):
|
|
1535
|
+
drop_db()
|
|
1536
|
+
|
|
1537
|
+
if sqlalchemy_utils.database_exists(db_url):
|
|
1538
|
+
raise RuntimeError(
|
|
1539
|
+
f"Database {db_url} already exists. Use --drop-existing-db "
|
|
1540
|
+
"to automatically drop existing database before start of test."
|
|
1541
|
+
)
|
|
1508
1542
|
|
|
1509
|
-
|
|
1510
|
-
|
|
1543
|
+
sqlalchemy_utils.create_database(
|
|
1544
|
+
db_url,
|
|
1545
|
+
encoding="utf8mb4' COLLATE = 'utf8mb4_unicode_ci"
|
|
1546
|
+
if db_url.startswith("mysql://")
|
|
1547
|
+
else "utf8",
|
|
1548
|
+
)
|
|
1511
1549
|
yield db_url
|
|
1512
1550
|
if db_url == "sqlite://" and persistent_database:
|
|
1513
1551
|
rmtree(tmp_path, ignore_errors=True)
|
|
1514
|
-
elif db_url.startswith("mysql://"):
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
made_url = sa.make_url(db_url)
|
|
1518
|
-
db = made_url.database
|
|
1519
|
-
engine = sa.create_engine(db_url)
|
|
1520
|
-
# Check for any open connections to the database before dropping it
|
|
1521
|
-
# to ensure that InnoDB does not deadlock.
|
|
1522
|
-
with engine.begin() as connection:
|
|
1523
|
-
query = sa.text(
|
|
1524
|
-
"select id FROM information_schema.processlist WHERE db=:db and id != CONNECTION_ID()"
|
|
1525
|
-
)
|
|
1526
|
-
rows = connection.execute(query, parameters={"db": db}).fetchall()
|
|
1527
|
-
if rows:
|
|
1528
|
-
raise RuntimeError(
|
|
1529
|
-
f"Unable to drop database {db} because it is in use by {rows}"
|
|
1530
|
-
)
|
|
1531
|
-
engine.dispose()
|
|
1532
|
-
sqlalchemy_utils.drop_database(db_url)
|
|
1533
|
-
elif db_url.startswith("postgresql://"):
|
|
1534
|
-
sqlalchemy_utils.drop_database(db_url)
|
|
1552
|
+
elif db_url.startswith(("mysql://", "postgresql://")):
|
|
1553
|
+
drop_db()
|
|
1535
1554
|
|
|
1536
1555
|
|
|
1537
1556
|
async def _async_init_recorder_component(
|
|
@@ -1661,10 +1680,12 @@ async def async_test_recorder(
|
|
|
1661
1680
|
migrate_entity_ids = (
|
|
1662
1681
|
migration.EntityIDMigration.migrate_data if enable_migrate_entity_ids else None
|
|
1663
1682
|
)
|
|
1664
|
-
|
|
1665
|
-
migration.EventIDPostMigration.
|
|
1683
|
+
post_migrate_event_ids = (
|
|
1684
|
+
migration.EventIDPostMigration.needs_migrate_impl
|
|
1666
1685
|
if enable_migrate_event_ids
|
|
1667
|
-
else lambda
|
|
1686
|
+
else lambda _1, _2, _3: migration.DataMigrationStatus(
|
|
1687
|
+
needs_migrate=False, migration_done=True
|
|
1688
|
+
)
|
|
1668
1689
|
)
|
|
1669
1690
|
with (
|
|
1670
1691
|
patch(
|
|
@@ -1703,8 +1724,8 @@ async def async_test_recorder(
|
|
|
1703
1724
|
autospec=True,
|
|
1704
1725
|
),
|
|
1705
1726
|
patch(
|
|
1706
|
-
"homeassistant.components.recorder.migration.EventIDPostMigration.
|
|
1707
|
-
side_effect=
|
|
1727
|
+
"homeassistant.components.recorder.migration.EventIDPostMigration.needs_migrate_impl",
|
|
1728
|
+
side_effect=post_migrate_event_ids,
|
|
1708
1729
|
autospec=True,
|
|
1709
1730
|
),
|
|
1710
1731
|
patch(
|
|
@@ -2114,3 +2135,14 @@ def _dhcp_service_info_init(self: DhcpServiceInfo, *args: Any, **kwargs: Any) ->
|
|
|
2114
2135
|
|
|
2115
2136
|
|
|
2116
2137
|
DhcpServiceInfo.__init__ = _dhcp_service_info_init
|
|
2138
|
+
|
|
2139
|
+
|
|
2140
|
+
@pytest.fixture(autouse=True)
|
|
2141
|
+
def disable_http_server() -> Generator[None]:
|
|
2142
|
+
"""Disable automatic start of HTTP server during tests.
|
|
2143
|
+
|
|
2144
|
+
This prevents the HTTP server from starting in tests that setup
|
|
2145
|
+
integrations which depend on the HTTP component.
|
|
2146
|
+
"""
|
|
2147
|
+
with patch("homeassistant.components.http.start_http_server_and_save_config"):
|
|
2148
|
+
yield
|
|
@@ -225,8 +225,8 @@ class AiohttpClientMockResponse:
|
|
|
225
225
|
|
|
226
226
|
if (
|
|
227
227
|
self._url.scheme != url.scheme
|
|
228
|
-
or self._url.
|
|
229
|
-
or self._url.
|
|
228
|
+
or self._url.raw_host != url.raw_host
|
|
229
|
+
or self._url.raw_path != url.raw_path
|
|
230
230
|
):
|
|
231
231
|
return False
|
|
232
232
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-homeassistant-custom-component
|
|
3
|
-
Version: 0.13.
|
|
3
|
+
Version: 0.13.303
|
|
4
4
|
Summary: Experimental package to automatically extract test plugins for Home Assistant custom components
|
|
5
5
|
Home-page: https://github.com/MatthewFlamm/pytest-homeassistant-custom-component
|
|
6
6
|
Author: Matthew Flamm
|
|
@@ -20,13 +20,12 @@ License-File: LICENSE_HA_CORE.md
|
|
|
20
20
|
Requires-Dist: sqlalchemy
|
|
21
21
|
Requires-Dist: coverage==7.10.6
|
|
22
22
|
Requires-Dist: freezegun==1.5.2
|
|
23
|
-
Requires-Dist: go2rtc-client==0.2.1
|
|
24
23
|
Requires-Dist: license-expression==30.4.3
|
|
25
24
|
Requires-Dist: mock-open==1.4.0
|
|
26
|
-
Requires-Dist: pydantic==2.
|
|
25
|
+
Requires-Dist: pydantic==2.12.2
|
|
27
26
|
Requires-Dist: pylint-per-file-ignores==1.4.0
|
|
28
27
|
Requires-Dist: pipdeptree==2.26.1
|
|
29
|
-
Requires-Dist: pytest-asyncio==1.
|
|
28
|
+
Requires-Dist: pytest-asyncio==1.3.0
|
|
30
29
|
Requires-Dist: pytest-aiohttp==1.1.0
|
|
31
30
|
Requires-Dist: pytest-cov==7.0.0
|
|
32
31
|
Requires-Dist: pytest-freezer==0.4.9
|
|
@@ -37,12 +36,12 @@ Requires-Dist: pytest-timeout==2.4.0
|
|
|
37
36
|
Requires-Dist: pytest-unordered==0.7.0
|
|
38
37
|
Requires-Dist: pytest-picked==0.5.1
|
|
39
38
|
Requires-Dist: pytest-xdist==3.8.0
|
|
40
|
-
Requires-Dist: pytest==
|
|
39
|
+
Requires-Dist: pytest==9.0.0
|
|
41
40
|
Requires-Dist: requests-mock==1.12.1
|
|
42
41
|
Requires-Dist: respx==0.22.0
|
|
43
|
-
Requires-Dist: syrupy==
|
|
42
|
+
Requires-Dist: syrupy==5.0.0
|
|
44
43
|
Requires-Dist: tqdm==4.67.1
|
|
45
|
-
Requires-Dist: homeassistant==
|
|
44
|
+
Requires-Dist: homeassistant==2026.1.0b3
|
|
46
45
|
Requires-Dist: SQLAlchemy==2.0.41
|
|
47
46
|
Requires-Dist: paho-mqtt==2.1.0
|
|
48
47
|
Requires-Dist: numpy==2.3.2
|
|
@@ -60,9 +59,7 @@ Dynamic: summary
|
|
|
60
59
|
|
|
61
60
|
# pytest-homeassistant-custom-component
|
|
62
61
|
|
|
63
|
-
](https://gitpod.io/#https://github.com/MatthewFlamm/pytest-homeassistant-custom-component)
|
|
62
|
+

|
|
66
63
|
|
|
67
64
|
Package to automatically extract testing plugins from Home Assistant for custom component testing.
|
|
68
65
|
The goal is to provide the same functionality as the tests in home-assistant/core.
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
sqlalchemy
|
|
2
2
|
coverage==7.10.6
|
|
3
3
|
freezegun==1.5.2
|
|
4
|
-
go2rtc-client==0.2.1
|
|
5
4
|
license-expression==30.4.3
|
|
6
5
|
mock-open==1.4.0
|
|
7
|
-
pydantic==2.
|
|
6
|
+
pydantic==2.12.2
|
|
8
7
|
pylint-per-file-ignores==1.4.0
|
|
9
8
|
pipdeptree==2.26.1
|
|
10
|
-
pytest-asyncio==1.
|
|
9
|
+
pytest-asyncio==1.3.0
|
|
11
10
|
pytest-aiohttp==1.1.0
|
|
12
11
|
pytest-cov==7.0.0
|
|
13
12
|
pytest-freezer==0.4.9
|
|
@@ -18,12 +17,12 @@ pytest-timeout==2.4.0
|
|
|
18
17
|
pytest-unordered==0.7.0
|
|
19
18
|
pytest-picked==0.5.1
|
|
20
19
|
pytest-xdist==3.8.0
|
|
21
|
-
pytest==
|
|
20
|
+
pytest==9.0.0
|
|
22
21
|
requests-mock==1.12.1
|
|
23
22
|
respx==0.22.0
|
|
24
|
-
syrupy==
|
|
23
|
+
syrupy==5.0.0
|
|
25
24
|
tqdm==4.67.1
|
|
26
|
-
homeassistant==
|
|
25
|
+
homeassistant==2026.1.0b3
|
|
27
26
|
SQLAlchemy==2.0.41
|
|
28
27
|
paho-mqtt==2.1.0
|
|
29
28
|
numpy==2.3.2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|