pytest-homeassistant-custom-component 0.13.269__py3-none-any.whl → 0.13.298__py3-none-any.whl

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.
@@ -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
  }
@@ -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, path: str, data_to_write: dict[str, Any]
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
- integration = loader.Integration(
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
- pathlib.Path(""),
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
  )
@@ -3,3 +3,354 @@ The tests for components.
3
3
 
4
4
  This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component.
5
5
  """
6
+
7
+ from enum import StrEnum
8
+ import itertools
9
+ from typing import TypedDict
10
+
11
+ from homeassistant.const import (
12
+ ATTR_AREA_ID,
13
+ ATTR_DEVICE_ID,
14
+ ATTR_FLOOR_ID,
15
+ ATTR_LABEL_ID,
16
+ CONF_ENTITY_ID,
17
+ CONF_OPTIONS,
18
+ CONF_PLATFORM,
19
+ CONF_TARGET,
20
+ STATE_UNAVAILABLE,
21
+ STATE_UNKNOWN,
22
+ )
23
+ from homeassistant.core import HomeAssistant
24
+ from homeassistant.helpers import (
25
+ area_registry as ar,
26
+ device_registry as dr,
27
+ entity_registry as er,
28
+ floor_registry as fr,
29
+ label_registry as lr,
30
+ )
31
+ from homeassistant.setup import async_setup_component
32
+
33
+ from ..common import MockConfigEntry, mock_device_registry
34
+
35
+
36
+ async def target_entities(
37
+ hass: HomeAssistant, domain: str
38
+ ) -> tuple[list[str], list[str]]:
39
+ """Create multiple entities associated with different targets.
40
+
41
+ Returns a dict with the following keys:
42
+ - included: List of entity_ids meant to be targeted.
43
+ - excluded: List of entity_ids not meant to be targeted.
44
+ """
45
+ await async_setup_component(hass, domain, {})
46
+
47
+ config_entry = MockConfigEntry(domain="test")
48
+ config_entry.add_to_hass(hass)
49
+
50
+ floor_reg = fr.async_get(hass)
51
+ floor = floor_reg.async_get_floor_by_name("Test Floor") or floor_reg.async_create(
52
+ "Test Floor"
53
+ )
54
+
55
+ area_reg = ar.async_get(hass)
56
+ area = area_reg.async_get_area_by_name("Test Area") or area_reg.async_create(
57
+ "Test Area", floor_id=floor.floor_id
58
+ )
59
+
60
+ label_reg = lr.async_get(hass)
61
+ label = label_reg.async_get_label_by_name("Test Label") or label_reg.async_create(
62
+ "Test Label"
63
+ )
64
+
65
+ device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
66
+ mock_device_registry(hass, {device.id: device})
67
+
68
+ entity_reg = er.async_get(hass)
69
+ # Entities associated with area
70
+ entity_area = entity_reg.async_get_or_create(
71
+ domain=domain,
72
+ platform="test",
73
+ unique_id=f"{domain}_area",
74
+ suggested_object_id=f"area_{domain}",
75
+ )
76
+ entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id)
77
+ entity_area_excluded = entity_reg.async_get_or_create(
78
+ domain=domain,
79
+ platform="test",
80
+ unique_id=f"{domain}_area_excluded",
81
+ suggested_object_id=f"area_{domain}_excluded",
82
+ )
83
+ entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id)
84
+
85
+ # Entities associated with device
86
+ entity_reg.async_get_or_create(
87
+ domain=domain,
88
+ platform="test",
89
+ unique_id=f"{domain}_device",
90
+ suggested_object_id=f"device_{domain}",
91
+ device_id=device.id,
92
+ )
93
+ entity_reg.async_get_or_create(
94
+ domain=domain,
95
+ platform="test",
96
+ unique_id=f"{domain}_device_excluded",
97
+ suggested_object_id=f"device_{domain}_excluded",
98
+ device_id=device.id,
99
+ )
100
+
101
+ # Entities associated with label
102
+ entity_label = entity_reg.async_get_or_create(
103
+ domain=domain,
104
+ platform="test",
105
+ unique_id=f"{domain}_label",
106
+ suggested_object_id=f"label_{domain}",
107
+ )
108
+ entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id})
109
+ entity_label_excluded = entity_reg.async_get_or_create(
110
+ domain=domain,
111
+ platform="test",
112
+ unique_id=f"{domain}_label_excluded",
113
+ suggested_object_id=f"label_{domain}_excluded",
114
+ )
115
+ entity_reg.async_update_entity(
116
+ entity_label_excluded.entity_id, labels={label.label_id}
117
+ )
118
+
119
+ # Return all available entities
120
+ return {
121
+ "included": [
122
+ f"{domain}.standalone_{domain}",
123
+ f"{domain}.label_{domain}",
124
+ f"{domain}.area_{domain}",
125
+ f"{domain}.device_{domain}",
126
+ ],
127
+ "excluded": [
128
+ f"{domain}.standalone_{domain}_excluded",
129
+ f"{domain}.label_{domain}_excluded",
130
+ f"{domain}.area_{domain}_excluded",
131
+ f"{domain}.device_{domain}_excluded",
132
+ ],
133
+ }
134
+
135
+
136
+ def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
137
+ """Parametrize target entities for different target types.
138
+
139
+ Meant to be used with target_entities.
140
+ """
141
+ return [
142
+ (
143
+ {CONF_ENTITY_ID: f"{domain}.standalone_{domain}"},
144
+ f"{domain}.standalone_{domain}",
145
+ 1,
146
+ ),
147
+ ({ATTR_LABEL_ID: "test_label"}, f"{domain}.label_{domain}", 2),
148
+ ({ATTR_AREA_ID: "test_area"}, f"{domain}.area_{domain}", 2),
149
+ ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.area_{domain}", 2),
150
+ ({ATTR_LABEL_ID: "test_label"}, f"{domain}.device_{domain}", 2),
151
+ ({ATTR_AREA_ID: "test_area"}, f"{domain}.device_{domain}", 2),
152
+ ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.device_{domain}", 2),
153
+ ({ATTR_DEVICE_ID: "test_device"}, f"{domain}.device_{domain}", 1),
154
+ ]
155
+
156
+
157
+ class _StateDescription(TypedDict):
158
+ """Test state and expected service call count."""
159
+
160
+ state: str | None
161
+ attributes: dict
162
+
163
+
164
+ class StateDescription(TypedDict):
165
+ """Test state and expected service call count."""
166
+
167
+ included: _StateDescription
168
+ excluded: _StateDescription
169
+ count: int
170
+
171
+
172
+ def parametrize_trigger_states(
173
+ *,
174
+ trigger: str,
175
+ target_states: list[str | None | tuple[str | None, dict]],
176
+ other_states: list[str | None | tuple[str | None, dict]],
177
+ additional_attributes: dict | None = None,
178
+ trigger_from_none: bool = True,
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
+ Returns a list of tuples with (trigger, list of states),
189
+ where states is a list of StateDescription dicts.
190
+ """
191
+
192
+ additional_attributes = additional_attributes or {}
193
+
194
+ def state_with_attributes(
195
+ state: str | None | tuple[str | None, dict], count: int
196
+ ) -> dict:
197
+ """Return (state, attributes) dict."""
198
+ if isinstance(state, str) or state is None:
199
+ return {
200
+ "included": {
201
+ "state": state,
202
+ "attributes": additional_attributes,
203
+ },
204
+ "excluded": {
205
+ "state": state,
206
+ "attributes": {},
207
+ },
208
+ "count": count,
209
+ }
210
+ return {
211
+ "included": {
212
+ "state": state[0],
213
+ "attributes": state[1] | additional_attributes,
214
+ },
215
+ "excluded": {
216
+ "state": state[0],
217
+ "attributes": state[1],
218
+ },
219
+ "count": count,
220
+ }
221
+
222
+ return [
223
+ # Initial state None
224
+ (
225
+ trigger,
226
+ list(
227
+ itertools.chain.from_iterable(
228
+ (
229
+ state_with_attributes(None, 0),
230
+ state_with_attributes(target_state, 0),
231
+ state_with_attributes(other_state, 0),
232
+ state_with_attributes(
233
+ target_state, 1 if trigger_from_none else 0
234
+ ),
235
+ )
236
+ for target_state in target_states
237
+ for other_state in other_states
238
+ )
239
+ ),
240
+ ),
241
+ # Initial state different from target state
242
+ (
243
+ trigger,
244
+ # other_state,
245
+ list(
246
+ itertools.chain.from_iterable(
247
+ (
248
+ state_with_attributes(other_state, 0),
249
+ state_with_attributes(target_state, 1),
250
+ state_with_attributes(other_state, 0),
251
+ state_with_attributes(target_state, 1),
252
+ )
253
+ for target_state in target_states
254
+ for other_state in other_states
255
+ )
256
+ ),
257
+ ),
258
+ # Initial state same as target state
259
+ (
260
+ trigger,
261
+ list(
262
+ itertools.chain.from_iterable(
263
+ (
264
+ state_with_attributes(target_state, 0),
265
+ state_with_attributes(target_state, 0),
266
+ state_with_attributes(other_state, 0),
267
+ state_with_attributes(target_state, 1),
268
+ )
269
+ for target_state in target_states
270
+ for other_state in other_states
271
+ )
272
+ ),
273
+ ),
274
+ # Initial state unavailable / unknown
275
+ (
276
+ trigger,
277
+ list(
278
+ itertools.chain.from_iterable(
279
+ (
280
+ state_with_attributes(STATE_UNAVAILABLE, 0),
281
+ state_with_attributes(target_state, 0),
282
+ state_with_attributes(other_state, 0),
283
+ state_with_attributes(target_state, 1),
284
+ )
285
+ for target_state in target_states
286
+ for other_state in other_states
287
+ )
288
+ ),
289
+ ),
290
+ (
291
+ trigger,
292
+ list(
293
+ itertools.chain.from_iterable(
294
+ (
295
+ state_with_attributes(STATE_UNKNOWN, 0),
296
+ state_with_attributes(target_state, 0),
297
+ state_with_attributes(other_state, 0),
298
+ state_with_attributes(target_state, 1),
299
+ )
300
+ for target_state in target_states
301
+ for other_state in other_states
302
+ )
303
+ ),
304
+ ),
305
+ ]
306
+
307
+
308
+ async def arm_trigger(
309
+ hass: HomeAssistant,
310
+ trigger: str,
311
+ trigger_options: dict | None,
312
+ trigger_target: dict,
313
+ ) -> None:
314
+ """Arm the specified trigger, call service test.automation when it triggers."""
315
+
316
+ # Local include to avoid importing the automation component unnecessarily
317
+ from homeassistant.components import automation # noqa: PLC0415
318
+
319
+ options = {CONF_OPTIONS: {**trigger_options}} if trigger_options is not None else {}
320
+
321
+ await async_setup_component(
322
+ hass,
323
+ automation.DOMAIN,
324
+ {
325
+ automation.DOMAIN: {
326
+ "trigger": {
327
+ CONF_PLATFORM: trigger,
328
+ CONF_TARGET: {**trigger_target},
329
+ }
330
+ | options,
331
+ "action": {
332
+ "service": "test.automation",
333
+ "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
334
+ },
335
+ }
336
+ },
337
+ )
338
+
339
+
340
+ def set_or_remove_state(
341
+ hass: HomeAssistant,
342
+ entity_id: str,
343
+ state: StateDescription,
344
+ ) -> None:
345
+ """Set or remove the state of an entity."""
346
+ if state["state"] is None:
347
+ hass.states.async_remove(entity_id)
348
+ else:
349
+ hass.states.async_set(
350
+ entity_id, state["state"], state["attributes"], force_update=True
351
+ )
352
+
353
+
354
+ def other_states(state: StrEnum) -> list[str]:
355
+ """Return a sorted list with all states except the specified one."""
356
+ return sorted({s.value for s in state.__class__} - {state.value})
@@ -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
 
@@ -456,6 +465,13 @@ def get_schema_module_path(schema_version_postfix: str) -> str:
456
465
  return f"...components.recorder.db_schema_{schema_version_postfix}"
457
466
 
458
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
+ )
473
+
474
+
459
475
  @contextmanager
460
476
  def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]:
461
477
  """Fixture to initialize the db with the old schema."""
@@ -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
@@ -5,7 +5,8 @@ This file is originally from homeassistant/core and modified by pytest-homeassis
5
5
  """
6
6
  from typing import TYPE_CHECKING, Final
7
7
  MAJOR_VERSION: Final = 2025
8
- MINOR_VERSION: Final = 8
8
+ MINOR_VERSION: Final = 12
9
9
  PATCH_VERSION: Final = "0"
10
10
  __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
11
11
  __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
12
+ CONF_API_VERSION: Final = "api_version"
@@ -30,8 +30,31 @@ def ha_datetime_to_fakedatetime(datetime) -> freezegun.api.FakeDatetime: # type
30
30
  )
31
31
 
32
32
 
33
- class HAFakeDatetime(freezegun.api.FakeDatetime): # type: ignore[name-defined]
34
- """Modified to include https://github.com/spulec/freezegun/pull/424."""
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):
@@ -103,6 +103,7 @@ from homeassistant.helpers import (
103
103
  translation as translation_helper,
104
104
  )
105
105
  from homeassistant.helpers.dispatcher import async_dispatcher_send
106
+ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
106
107
  from homeassistant.helpers.translation import _TranslationsCacheData
107
108
  from homeassistant.helpers.typing import ConfigType
108
109
  from homeassistant.setup import async_setup_component
@@ -162,6 +163,7 @@ asyncio.set_event_loop_policy = lambda policy: None
162
163
  def pytest_addoption(parser: pytest.Parser) -> None:
163
164
  """Register custom pytest options."""
164
165
  parser.addoption("--dburl", action="store", default="sqlite://")
166
+ parser.addoption("--drop-existing-db", action="store_const", const=True)
165
167
 
166
168
 
167
169
  def pytest_configure(config: pytest.Config) -> None:
@@ -189,11 +191,13 @@ def pytest_runtest_setup() -> None:
189
191
  destinations will be allowed.
190
192
 
191
193
  freezegun:
192
- 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.
193
195
  """
194
196
  pytest_socket.socket_allow_hosts(["127.0.0.1"])
195
197
  pytest_socket.disable_socket(allow_unix_socket=True)
196
198
 
199
+ freezegun.api.FakeDate = patch_time.HAFakeDate # type: ignore[attr-defined]
200
+
197
201
  freezegun.api.datetime_to_fakedatetime = patch_time.ha_datetime_to_fakedatetime # type: ignore[attr-defined]
198
202
  freezegun.api.FakeDatetime = patch_time.HAFakeDatetime # type: ignore[attr-defined]
199
203
 
@@ -1493,44 +1497,58 @@ def recorder_db_url(
1493
1497
  assert not hass_fixture_setup
1494
1498
 
1495
1499
  db_url = cast(str, pytestconfig.getoption("dburl"))
1500
+ drop_existing_db = pytestconfig.getoption("drop_existing_db")
1501
+
1502
+ def drop_db() -> None:
1503
+ import sqlalchemy as sa # noqa: PLC0415
1504
+ import sqlalchemy_utils # noqa: PLC0415
1505
+
1506
+ if db_url.startswith("mysql://"):
1507
+ made_url = sa.make_url(db_url)
1508
+ db = made_url.database
1509
+ engine = sa.create_engine(db_url)
1510
+ # Check for any open connections to the database before dropping it
1511
+ # to ensure that InnoDB does not deadlock.
1512
+ with engine.begin() as connection:
1513
+ query = sa.text(
1514
+ "select id FROM information_schema.processlist WHERE db=:db and id != CONNECTION_ID()"
1515
+ )
1516
+ rows = connection.execute(query, parameters={"db": db}).fetchall()
1517
+ if rows:
1518
+ raise RuntimeError(
1519
+ f"Unable to drop database {db} because it is in use by {rows}"
1520
+ )
1521
+ engine.dispose()
1522
+ sqlalchemy_utils.drop_database(db_url)
1523
+ elif db_url.startswith("postgresql://"):
1524
+ sqlalchemy_utils.drop_database(db_url)
1525
+
1496
1526
  if db_url == "sqlite://" and persistent_database:
1497
1527
  tmp_path = tmp_path_factory.mktemp("recorder")
1498
1528
  db_url = "sqlite:///" + str(tmp_path / "pytest.db")
1499
- elif db_url.startswith("mysql://"):
1529
+ elif db_url.startswith(("mysql://", "postgresql://")):
1500
1530
  import sqlalchemy_utils # noqa: PLC0415
1501
1531
 
1502
- charset = "utf8mb4' COLLATE = 'utf8mb4_unicode_ci"
1503
- assert not sqlalchemy_utils.database_exists(db_url)
1504
- sqlalchemy_utils.create_database(db_url, encoding=charset)
1505
- elif db_url.startswith("postgresql://"):
1506
- import sqlalchemy_utils # noqa: PLC0415
1532
+ if drop_existing_db and sqlalchemy_utils.database_exists(db_url):
1533
+ drop_db()
1507
1534
 
1508
- assert not sqlalchemy_utils.database_exists(db_url)
1509
- sqlalchemy_utils.create_database(db_url, encoding="utf8")
1535
+ if sqlalchemy_utils.database_exists(db_url):
1536
+ raise RuntimeError(
1537
+ f"Database {db_url} already exists. Use --drop-existing-db "
1538
+ "to automatically drop existing database before start of test."
1539
+ )
1540
+
1541
+ sqlalchemy_utils.create_database(
1542
+ db_url,
1543
+ encoding="utf8mb4' COLLATE = 'utf8mb4_unicode_ci"
1544
+ if db_url.startswith("mysql://")
1545
+ else "utf8",
1546
+ )
1510
1547
  yield db_url
1511
1548
  if db_url == "sqlite://" and persistent_database:
1512
1549
  rmtree(tmp_path, ignore_errors=True)
1513
- elif db_url.startswith("mysql://"):
1514
- import sqlalchemy as sa # noqa: PLC0415
1515
-
1516
- made_url = sa.make_url(db_url)
1517
- db = made_url.database
1518
- engine = sa.create_engine(db_url)
1519
- # Check for any open connections to the database before dropping it
1520
- # to ensure that InnoDB does not deadlock.
1521
- with engine.begin() as connection:
1522
- query = sa.text(
1523
- "select id FROM information_schema.processlist WHERE db=:db and id != CONNECTION_ID()"
1524
- )
1525
- rows = connection.execute(query, parameters={"db": db}).fetchall()
1526
- if rows:
1527
- raise RuntimeError(
1528
- f"Unable to drop database {db} because it is in use by {rows}"
1529
- )
1530
- engine.dispose()
1531
- sqlalchemy_utils.drop_database(db_url)
1532
- elif db_url.startswith("postgresql://"):
1533
- sqlalchemy_utils.drop_database(db_url)
1550
+ elif db_url.startswith(("mysql://", "postgresql://")):
1551
+ drop_db()
1534
1552
 
1535
1553
 
1536
1554
  async def _async_init_recorder_component(
@@ -1660,10 +1678,12 @@ async def async_test_recorder(
1660
1678
  migrate_entity_ids = (
1661
1679
  migration.EntityIDMigration.migrate_data if enable_migrate_entity_ids else None
1662
1680
  )
1663
- legacy_event_id_foreign_key_exists = (
1664
- migration.EventIDPostMigration._legacy_event_id_foreign_key_exists
1681
+ post_migrate_event_ids = (
1682
+ migration.EventIDPostMigration.needs_migrate_impl
1665
1683
  if enable_migrate_event_ids
1666
- else lambda _: None
1684
+ else lambda _1, _2, _3: migration.DataMigrationStatus(
1685
+ needs_migrate=False, migration_done=True
1686
+ )
1667
1687
  )
1668
1688
  with (
1669
1689
  patch(
@@ -1702,8 +1722,8 @@ async def async_test_recorder(
1702
1722
  autospec=True,
1703
1723
  ),
1704
1724
  patch(
1705
- "homeassistant.components.recorder.migration.EventIDPostMigration._legacy_event_id_foreign_key_exists",
1706
- side_effect=legacy_event_id_foreign_key_exists,
1725
+ "homeassistant.components.recorder.migration.EventIDPostMigration.needs_migrate_impl",
1726
+ side_effect=post_migrate_event_ids,
1707
1727
  autospec=True,
1708
1728
  ),
1709
1729
  patch(
@@ -1821,6 +1841,7 @@ async def mock_enable_bluetooth(
1821
1841
  def mock_bluetooth_adapters() -> Generator[None]:
1822
1842
  """Fixture to mock bluetooth adapters."""
1823
1843
  with (
1844
+ patch("habluetooth.util.recover_adapter"),
1824
1845
  patch("bluetooth_auto_recovery.recover_adapter"),
1825
1846
  patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),
1826
1847
  patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"),
@@ -1849,19 +1870,31 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]:
1849
1870
 
1850
1871
  # Late imports to avoid loading bleak unless we need it
1851
1872
 
1852
- from habluetooth import scanner as bluetooth_scanner # noqa: PLC0415
1873
+ from habluetooth import ( # noqa: PLC0415
1874
+ manager as bluetooth_manager,
1875
+ scanner as bluetooth_scanner,
1876
+ )
1853
1877
 
1854
1878
  # We need to drop the stop method from the object since we patched
1855
1879
  # out start and this fixture will expire before the stop method is called
1856
1880
  # when EVENT_HOMEASSISTANT_STOP is fired.
1857
1881
  # pylint: disable-next=c-extension-no-member
1858
1882
  bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment]
1883
+
1884
+ # Mock BlueZ management controller to successfully setup
1885
+ # This prevents the manager from operating in degraded mode
1886
+ mock_mgmt_bluetooth_ctl = Mock()
1887
+ mock_mgmt_bluetooth_ctl.setup = AsyncMock(return_value=None)
1888
+
1859
1889
  with (
1860
1890
  patch.object(
1861
1891
  bluetooth_scanner.OriginalBleakScanner, # pylint: disable=c-extension-no-member
1862
1892
  "start",
1863
1893
  ) as mock_bleak_scanner_start,
1864
1894
  patch.object(bluetooth_scanner, "HaScanner"),
1895
+ patch.object(
1896
+ bluetooth_manager, "MGMTBluetoothCtl", return_value=mock_mgmt_bluetooth_ctl
1897
+ ),
1865
1898
  ):
1866
1899
  yield mock_bleak_scanner_start
1867
1900
 
@@ -2082,3 +2115,32 @@ def disable_block_async_io() -> Generator[None]:
2082
2115
  blocking_call.object, blocking_call.function, blocking_call.original_func
2083
2116
  )
2084
2117
  calls.clear()
2118
+
2119
+
2120
+ # Ensure that incorrectly formatted mac addresses are rejected during
2121
+ # DhcpServiceInfo initialisation
2122
+ _real_dhcp_service_info_init = DhcpServiceInfo.__init__
2123
+
2124
+
2125
+ def _dhcp_service_info_init(self: DhcpServiceInfo, *args: Any, **kwargs: Any) -> None:
2126
+ """Override __init__ for DhcpServiceInfo.
2127
+
2128
+ Ensure that the macaddress is always in lowercase and without colons to match DHCP service.
2129
+ """
2130
+ _real_dhcp_service_info_init(self, *args, **kwargs)
2131
+ if self.macaddress != self.macaddress.lower().replace(":", ""):
2132
+ raise ValueError("macaddress is not correctly formatted")
2133
+
2134
+
2135
+ DhcpServiceInfo.__init__ = _dhcp_service_info_init
2136
+
2137
+
2138
+ @pytest.fixture(autouse=True)
2139
+ def disable_http_server() -> Generator[None]:
2140
+ """Disable automatic start of HTTP server during .
2141
+
2142
+ This prevents the HTTP server from starting in tests that setup
2143
+ integrations which depend on the HTTP component.
2144
+ """
2145
+ with patch("homeassistant.components.http.start_http_server_and_save_config"):
2146
+ yield
@@ -225,8 +225,8 @@ class AiohttpClientMockResponse:
225
225
 
226
226
  if (
227
227
  self._url.scheme != url.scheme
228
- or self._url.host != url.host
229
- or self._url.path != url.path
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.269
3
+ Version: 0.13.298
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
@@ -18,17 +18,18 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  License-File: LICENSE_HA_CORE.md
20
20
  Requires-Dist: sqlalchemy
21
- Requires-Dist: coverage==7.10.0
21
+ Requires-Dist: coverage==7.10.6
22
22
  Requires-Dist: freezegun==1.5.2
23
- Requires-Dist: go2rtc-client==0.2.1
23
+ Requires-Dist: go2rtc-client==0.3.0
24
+ Requires-Dist: librt==0.2.1
24
25
  Requires-Dist: license-expression==30.4.3
25
26
  Requires-Dist: mock-open==1.4.0
26
- Requires-Dist: pydantic==2.11.7
27
+ Requires-Dist: pydantic==2.12.2
27
28
  Requires-Dist: pylint-per-file-ignores==1.4.0
28
29
  Requires-Dist: pipdeptree==2.26.1
29
- Requires-Dist: pytest-asyncio==1.1.0
30
+ Requires-Dist: pytest-asyncio==1.3.0
30
31
  Requires-Dist: pytest-aiohttp==1.1.0
31
- Requires-Dist: pytest-cov==6.2.1
32
+ Requires-Dist: pytest-cov==7.0.0
32
33
  Requires-Dist: pytest-freezer==0.4.9
33
34
  Requires-Dist: pytest-github-actions-annotate-failures==0.3.0
34
35
  Requires-Dist: pytest-socket==0.7.0
@@ -37,12 +38,12 @@ Requires-Dist: pytest-timeout==2.4.0
37
38
  Requires-Dist: pytest-unordered==0.7.0
38
39
  Requires-Dist: pytest-picked==0.5.1
39
40
  Requires-Dist: pytest-xdist==3.8.0
40
- Requires-Dist: pytest==8.4.1
41
+ Requires-Dist: pytest==9.0.0
41
42
  Requires-Dist: requests-mock==1.12.1
42
43
  Requires-Dist: respx==0.22.0
43
- Requires-Dist: syrupy==4.9.1
44
+ Requires-Dist: syrupy==5.0.0
44
45
  Requires-Dist: tqdm==4.67.1
45
- Requires-Dist: homeassistant==2025.8.0
46
+ Requires-Dist: homeassistant==2025.12.0
46
47
  Requires-Dist: SQLAlchemy==2.0.41
47
48
  Requires-Dist: paho-mqtt==2.1.0
48
49
  Requires-Dist: numpy==2.3.2
@@ -60,7 +61,7 @@ Dynamic: summary
60
61
 
61
62
  # pytest-homeassistant-custom-component
62
63
 
63
- ![HA core version](https://img.shields.io/static/v1?label=HA+core+version&message=2025.8.0&labelColor=blue)
64
+ ![HA core version](https://img.shields.io/static/v1?label=HA+core+version&message=2025.12.0&labelColor=blue)
64
65
 
65
66
  [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/MatthewFlamm/pytest-homeassistant-custom-component)
66
67
 
@@ -1,28 +1,28 @@
1
1
  pytest_homeassistant_custom_component/__init__.py,sha256=pUI8j-H-57ncCLnvZSDWZPCtJpvi3ACZqPtH5SbedZA,138
2
2
  pytest_homeassistant_custom_component/asyncio_legacy.py,sha256=UdkV2mKqeS21QX9LSdBYsBRbm2h4JCVVZeesaOLKOAE,3886
3
- pytest_homeassistant_custom_component/common.py,sha256=KU5RmDkw_OOk20AYP9WJPoLFoFEbYX0JVdsY8DN6nKk,65399
4
- pytest_homeassistant_custom_component/const.py,sha256=dIm4ZSDxH9UeW2gqrnSRkUwl0AO1-LixqZXZG3cAAMo,399
3
+ pytest_homeassistant_custom_component/common.py,sha256=1UprBAnCk8VgxwD2Py893jNr0Fsjn-1Q_vx7pYcjB1M,65480
4
+ pytest_homeassistant_custom_component/const.py,sha256=ytAygDIdVtA6OiG_jBkWveMPpCDEz52T9zCZ4vsuQJ8,440
5
5
  pytest_homeassistant_custom_component/ignore_uncaught_exceptions.py,sha256=rilak_dQGMNhDqST1ZzhjZl_qmytFjkcez0vYmLMQ4Q,1601
6
6
  pytest_homeassistant_custom_component/patch_json.py,sha256=hNUeb1yxAr7ONfvX-o_WkI6zhQDCdKl7GglPjkVUiHo,1063
7
7
  pytest_homeassistant_custom_component/patch_recorder.py,sha256=lW8N_3ZIKQ5lsVjRc-ROo7d0egUZcpjquWKqe7iEF94,819
8
- pytest_homeassistant_custom_component/patch_time.py,sha256=G6RcRJNvl8q6qcbvknA21dsZdoF5gfQDh46PptWAwWA,2509
9
- pytest_homeassistant_custom_component/plugins.py,sha256=YNSYDRonL-Gevj2wV7HP6ZB9vQqrNztMrtUru3BA7FE,67754
8
+ pytest_homeassistant_custom_component/patch_time.py,sha256=jdnOAXDxUA0AKqvyeSrRC18rHDGfcpWYuLhmUglebCE,3374
9
+ pytest_homeassistant_custom_component/plugins.py,sha256=ui8WsonovfIEb0eI-UkV6IE80fm63_YUHD9RzD6wj2k,69969
10
10
  pytest_homeassistant_custom_component/syrupy.py,sha256=N_g_90dWqruzUogQi0rJsuN0XRbA6ffJen62r8P9cdo,15588
11
11
  pytest_homeassistant_custom_component/typing.py,sha256=zGhdf6U6aRq5cPwIfRUdtZeApLOyPD2EArjznKoIRZM,1734
12
- pytest_homeassistant_custom_component/components/__init__.py,sha256=0BHCdArl5gPjDJWaZrqvApHvzL_29FbE1RMg_mg__Qs,138
12
+ pytest_homeassistant_custom_component/components/__init__.py,sha256=49s3Tf-mHQQnQPnjuD94LeCnYDypnm6y7Dhfr5lJkCQ,11427
13
13
  pytest_homeassistant_custom_component/components/diagnostics/__init__.py,sha256=O_ys8t0iHvRorFr4TrR9k3sa3Xh5qBb4HsylY775UFA,2431
14
14
  pytest_homeassistant_custom_component/components/recorder/__init__.py,sha256=ugrLzvjSQFSmYRjy88ZZSiyA-NLgKlLkFp0OKguy6a4,225
15
- pytest_homeassistant_custom_component/components/recorder/common.py,sha256=SoKcNp_rSWMcMUxkbhZ193L67OYBhUbAX1X8IGvIjbA,17943
15
+ pytest_homeassistant_custom_component/components/recorder/common.py,sha256=8c_oqbQtg7dI-JoOaZrLYKFAjEJvjvSIA1atfui-WpQ,22091
16
16
  pytest_homeassistant_custom_component/components/recorder/db_schema_0.py,sha256=0mez9slhL-I286dDAxq06UDvWRU6RzCA2GKOwtj9JOI,5547
17
17
  pytest_homeassistant_custom_component/test_util/__init__.py,sha256=ljLmNeblq1vEgP0vhf2P1-SuyGSHvLKVA0APSYA0Xl8,1034
18
- pytest_homeassistant_custom_component/test_util/aiohttp.py,sha256=oPQaFRgXcAfHj9dE2Rjl1UJCBfhQp80CnQV02rXMYLo,11520
18
+ pytest_homeassistant_custom_component/test_util/aiohttp.py,sha256=sJHmGf4Oig0SUMvfylBaZIsDTfpTwmYvuLfE--OXYx4,11536
19
19
  pytest_homeassistant_custom_component/testing_config/__init__.py,sha256=SRp6h9HJi2I_vA6cPNkMiR0BTYib5XVmL03H-l3BPL0,158
20
20
  pytest_homeassistant_custom_component/testing_config/custom_components/__init__.py,sha256=-l6KCBLhwEDkCztlY6S-j53CjmKY6-A_3eX5JVS02NY,173
21
21
  pytest_homeassistant_custom_component/testing_config/custom_components/test_constant_deprecation/__init__.py,sha256=2vF_C-VP9tDjZMX7h6iJRAugtH2Bf3b4fE3i9j4vGeY,383
22
- pytest_homeassistant_custom_component-0.13.269.dist-info/licenses/LICENSE,sha256=7h-vqUxyeQNXiQgRJ8350CSHOy55M07DZuv4KG70AS8,1070
23
- pytest_homeassistant_custom_component-0.13.269.dist-info/licenses/LICENSE_HA_CORE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
24
- pytest_homeassistant_custom_component-0.13.269.dist-info/METADATA,sha256=-jyYROuBhpEMKMTtDwNJd3CwFo-Cb6l7ofsyFuYlwmc,5928
25
- pytest_homeassistant_custom_component-0.13.269.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
- pytest_homeassistant_custom_component-0.13.269.dist-info/entry_points.txt,sha256=bOCTSuP8RSPg0QfwdfurUShvMGWg4MI2F8rxbWx-VtQ,73
27
- pytest_homeassistant_custom_component-0.13.269.dist-info/top_level.txt,sha256=PR2cize2la22eOO7dQChJWK8dkJnuMmDC-fhafmdOWw,38
28
- pytest_homeassistant_custom_component-0.13.269.dist-info/RECORD,,
22
+ pytest_homeassistant_custom_component-0.13.298.dist-info/licenses/LICENSE,sha256=7h-vqUxyeQNXiQgRJ8350CSHOy55M07DZuv4KG70AS8,1070
23
+ pytest_homeassistant_custom_component-0.13.298.dist-info/licenses/LICENSE_HA_CORE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
24
+ pytest_homeassistant_custom_component-0.13.298.dist-info/METADATA,sha256=a6kWgh27JmV2RF1vvLgklpSKMsH206TFldacwEQBwM0,5958
25
+ pytest_homeassistant_custom_component-0.13.298.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
+ pytest_homeassistant_custom_component-0.13.298.dist-info/entry_points.txt,sha256=bOCTSuP8RSPg0QfwdfurUShvMGWg4MI2F8rxbWx-VtQ,73
27
+ pytest_homeassistant_custom_component-0.13.298.dist-info/top_level.txt,sha256=PR2cize2la22eOO7dQChJWK8dkJnuMmDC-fhafmdOWw,38
28
+ pytest_homeassistant_custom_component-0.13.298.dist-info/RECORD,,