aiohomematic-test-support 2025.12.8__tar.gz → 2025.12.26__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.
- {aiohomematic_test_support-2025.12.8/aiohomematic_test_support.egg-info → aiohomematic_test_support-2025.12.26}/PKG-INFO +1 -1
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/__init__.py +2 -2
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26/aiohomematic_test_support.egg-info}/PKG-INFO +1 -1
- aiohomematic_test_support-2025.12.26/data/full_session_randomized_ccu.zip +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/factory.py +46 -20
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/helper.py +1 -1
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/mock.py +169 -14
- aiohomematic_test_support-2025.12.8/data/full_session_randomized_ccu.zip +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/MANIFEST.in +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/README.md +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/aiohomematic_test_support.egg-info/SOURCES.txt +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/aiohomematic_test_support.egg-info/dependency_links.txt +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/aiohomematic_test_support.egg-info/top_level.txt +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/const.py +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/data/full_session_randomized_pydevccu.zip +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/py.typed +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/pyproject.toml +0 -0
- {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohomematic-test-support
|
|
3
|
-
Version: 2025.12.
|
|
3
|
+
Version: 2025.12.26
|
|
4
4
|
Summary: Support-only package for AioHomematic (tests/dev). Not part of production builds.
|
|
5
5
|
Author-email: SukramJ <sukramj@icloud.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/SukramJ/aiohomematic
|
|
@@ -30,7 +30,7 @@ Using the factory to create a test central with session playback:
|
|
|
30
30
|
|
|
31
31
|
# Test operations
|
|
32
32
|
await central.start()
|
|
33
|
-
device = central.get_device_by_address("VCU0000001")
|
|
33
|
+
device = central.device_coordinator.get_device_by_address("VCU0000001")
|
|
34
34
|
await central.stop()
|
|
35
35
|
|
|
36
36
|
The session player replays pre-recorded backend responses, enabling fast and
|
|
@@ -45,4 +45,4 @@ test dependencies to access test support functionality.
|
|
|
45
45
|
|
|
46
46
|
from __future__ import annotations
|
|
47
47
|
|
|
48
|
-
__version__ = "2025.12.
|
|
48
|
+
__version__ = "2025.12.26"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohomematic-test-support
|
|
3
|
-
Version: 2025.12.
|
|
3
|
+
Version: 2025.12.26
|
|
4
4
|
Summary: Support-only package for AioHomematic (tests/dev). Not part of production builds.
|
|
5
5
|
Author-email: SukramJ <sukramj@icloud.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/SukramJ/aiohomematic
|
|
@@ -41,10 +41,16 @@ from unittest.mock import MagicMock, Mock, patch
|
|
|
41
41
|
from aiohttp import ClientSession
|
|
42
42
|
|
|
43
43
|
from aiohomematic.central import CentralConfig, CentralUnit
|
|
44
|
-
from aiohomematic.central.
|
|
44
|
+
from aiohomematic.central.integration_events import (
|
|
45
|
+
DataPointsCreatedEvent,
|
|
46
|
+
DeviceLifecycleEvent,
|
|
47
|
+
DeviceLifecycleEventType,
|
|
48
|
+
DeviceTriggerEvent,
|
|
49
|
+
SystemStatusEvent,
|
|
50
|
+
)
|
|
45
51
|
from aiohomematic.client import ClientConfig, InterfaceConfig
|
|
46
|
-
from aiohomematic.const import LOCAL_HOST,
|
|
47
|
-
from aiohomematic.interfaces import ClientProtocol
|
|
52
|
+
from aiohomematic.const import LOCAL_HOST, Interface, OptionalSettings
|
|
53
|
+
from aiohomematic.interfaces.client import ClientProtocol
|
|
48
54
|
from aiohomematic_test_support import const
|
|
49
55
|
from aiohomematic_test_support.mock import SessionPlayer, get_client_session, get_mock, get_xml_rpc_proxy
|
|
50
56
|
|
|
@@ -81,13 +87,13 @@ class FactoryWithClient:
|
|
|
81
87
|
)
|
|
82
88
|
self.system_event_mock = MagicMock()
|
|
83
89
|
self.ha_event_mock = MagicMock()
|
|
84
|
-
self.
|
|
90
|
+
self._event_bus_unsubscribe_callbacks: list[Callable[[], None]] = []
|
|
85
91
|
|
|
86
92
|
def cleanup_event_bus_subscriptions(self) -> None:
|
|
87
93
|
"""Clean up all event bus subscriptions."""
|
|
88
|
-
for unsubscribe in self.
|
|
94
|
+
for unsubscribe in self._event_bus_unsubscribe_callbacks:
|
|
89
95
|
unsubscribe()
|
|
90
|
-
self.
|
|
96
|
+
self._event_bus_unsubscribe_callbacks.clear()
|
|
91
97
|
|
|
92
98
|
async def get_default_central(self, *, start: bool = True) -> CentralUnit:
|
|
93
99
|
"""Return a central based on give address_device_translation."""
|
|
@@ -138,21 +144,41 @@ class FactoryWithClient:
|
|
|
138
144
|
).create_central()
|
|
139
145
|
|
|
140
146
|
# Subscribe to events via event bus
|
|
141
|
-
def
|
|
142
|
-
"""Handle
|
|
147
|
+
def _device_lifecycle_event_handler(event: DeviceLifecycleEvent) -> None:
|
|
148
|
+
"""Handle device lifecycle events."""
|
|
143
149
|
self.system_event_mock(event)
|
|
144
150
|
|
|
145
|
-
def
|
|
146
|
-
"""Handle
|
|
151
|
+
def _data_points_created_event_handler(event: DataPointsCreatedEvent) -> None:
|
|
152
|
+
"""Handle data points created events."""
|
|
153
|
+
self.system_event_mock(event)
|
|
154
|
+
|
|
155
|
+
def _device_trigger_event_handler(event: DeviceTriggerEvent) -> None:
|
|
156
|
+
"""Handle device trigger events."""
|
|
157
|
+
self.ha_event_mock(event)
|
|
158
|
+
|
|
159
|
+
def _system_status_event_handler(event: SystemStatusEvent) -> None:
|
|
160
|
+
"""Handle system status events (issues, state changes)."""
|
|
147
161
|
self.ha_event_mock(event)
|
|
148
162
|
|
|
149
|
-
self.
|
|
163
|
+
self._event_bus_unsubscribe_callbacks.append(
|
|
164
|
+
central.event_bus.subscribe(
|
|
165
|
+
event_type=DeviceLifecycleEvent, event_key=None, handler=_device_lifecycle_event_handler
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
self._event_bus_unsubscribe_callbacks.append(
|
|
169
|
+
central.event_bus.subscribe(
|
|
170
|
+
event_type=DataPointsCreatedEvent, event_key=None, handler=_data_points_created_event_handler
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
self._event_bus_unsubscribe_callbacks.append(
|
|
150
174
|
central.event_bus.subscribe(
|
|
151
|
-
event_type=
|
|
175
|
+
event_type=DeviceTriggerEvent, event_key=None, handler=_device_trigger_event_handler
|
|
152
176
|
)
|
|
153
177
|
)
|
|
154
|
-
self.
|
|
155
|
-
central.event_bus.subscribe(
|
|
178
|
+
self._event_bus_unsubscribe_callbacks.append(
|
|
179
|
+
central.event_bus.subscribe(
|
|
180
|
+
event_type=SystemStatusEvent, event_key=None, handler=_system_status_event_handler
|
|
181
|
+
)
|
|
156
182
|
)
|
|
157
183
|
|
|
158
184
|
assert central
|
|
@@ -223,14 +249,14 @@ async def get_central_client_factory(
|
|
|
223
249
|
un_ignore_list=un_ignore_list,
|
|
224
250
|
)
|
|
225
251
|
central = await factory.get_default_central()
|
|
226
|
-
client = central.primary_client
|
|
252
|
+
client = central.client_coordinator.primary_client
|
|
227
253
|
assert client
|
|
228
254
|
try:
|
|
229
255
|
yield central, client, factory
|
|
230
256
|
finally:
|
|
231
257
|
factory.cleanup_event_bus_subscriptions()
|
|
232
258
|
await central.stop()
|
|
233
|
-
await central.
|
|
259
|
+
await central.cache_coordinator.clear_all()
|
|
234
260
|
|
|
235
261
|
|
|
236
262
|
async def get_pydev_ccu_central_unit_full(
|
|
@@ -241,9 +267,9 @@ async def get_pydev_ccu_central_unit_full(
|
|
|
241
267
|
"""Create and yield central, after all devices have been created."""
|
|
242
268
|
device_event = asyncio.Event()
|
|
243
269
|
|
|
244
|
-
def
|
|
245
|
-
"""Handle
|
|
246
|
-
if event.
|
|
270
|
+
def device_lifecycle_event_handler(event: DeviceLifecycleEvent) -> None:
|
|
271
|
+
"""Handle device lifecycle events."""
|
|
272
|
+
if event.event_type == DeviceLifecycleEventType.CREATED:
|
|
247
273
|
device_event.set()
|
|
248
274
|
|
|
249
275
|
interface_configs = {
|
|
@@ -265,7 +291,7 @@ async def get_pydev_ccu_central_unit_full(
|
|
|
265
291
|
program_markers=(),
|
|
266
292
|
sysvar_markers=(),
|
|
267
293
|
).create_central()
|
|
268
|
-
central.event_bus.subscribe(event_type=
|
|
294
|
+
central.event_bus.subscribe(event_type=DeviceLifecycleEvent, event_key=None, handler=device_lifecycle_event_handler)
|
|
269
295
|
await central.start()
|
|
270
296
|
|
|
271
297
|
# Wait up to 60 seconds for the DEVICES_CREATED event which signals that all devices are available
|
|
@@ -11,7 +11,7 @@ import orjson
|
|
|
11
11
|
|
|
12
12
|
from aiohomematic.central import CentralUnit
|
|
13
13
|
from aiohomematic.const import UTF_8
|
|
14
|
-
from aiohomematic.interfaces import CustomDataPointProtocol
|
|
14
|
+
from aiohomematic.interfaces.model import CustomDataPointProtocol
|
|
15
15
|
|
|
16
16
|
_LOGGER = logging.getLogger(__name__)
|
|
17
17
|
|
|
@@ -37,6 +37,8 @@ from __future__ import annotations
|
|
|
37
37
|
|
|
38
38
|
import asyncio
|
|
39
39
|
from collections import defaultdict
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
import contextlib
|
|
40
42
|
import json
|
|
41
43
|
import logging
|
|
42
44
|
import os
|
|
@@ -52,7 +54,7 @@ from aiohomematic.client import BaseRpcProxy
|
|
|
52
54
|
from aiohomematic.client.json_rpc import _JsonKey, _JsonRpcMethod
|
|
53
55
|
from aiohomematic.client.rpc_proxy import _RpcMethod
|
|
54
56
|
from aiohomematic.const import UTF_8, DataOperationResult, Parameter, ParamsetKey, RPCType
|
|
55
|
-
from aiohomematic.store.persistent import _freeze_params, _unfreeze_params
|
|
57
|
+
from aiohomematic.store.persistent import _cleanup_params_for_session, _freeze_params, _unfreeze_params
|
|
56
58
|
from aiohomematic_test_support import const
|
|
57
59
|
|
|
58
60
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -184,7 +186,7 @@ def get_client_session( # noqa: C901
|
|
|
184
186
|
)
|
|
185
187
|
|
|
186
188
|
if method == _JsonRpcMethod.INTERFACE_SET_VALUE:
|
|
187
|
-
await self._central.data_point_event(
|
|
189
|
+
await self._central.event_coordinator.data_point_event(
|
|
188
190
|
interface_id=params[_JsonKey.INTERFACE],
|
|
189
191
|
channel_address=params[_JsonKey.ADDRESS],
|
|
190
192
|
parameter=params[_JsonKey.VALUE_KEY],
|
|
@@ -197,7 +199,7 @@ def get_client_session( # noqa: C901
|
|
|
197
199
|
channel_address = params[_JsonKey.ADDRESS]
|
|
198
200
|
values = params[_JsonKey.SET]
|
|
199
201
|
for param, value in values.items():
|
|
200
|
-
await self._central.data_point_event(
|
|
202
|
+
await self._central.event_coordinator.data_point_event(
|
|
201
203
|
interface_id=interface_id,
|
|
202
204
|
channel_address=channel_address,
|
|
203
205
|
parameter=param,
|
|
@@ -277,6 +279,9 @@ def get_xml_rpc_proxy( # noqa: C901
|
|
|
277
279
|
"""Return the supported methods."""
|
|
278
280
|
return self._supported_methods
|
|
279
281
|
|
|
282
|
+
def clear_connection_issue(self) -> None:
|
|
283
|
+
"""Clear connection issue (no-op for test mock)."""
|
|
284
|
+
|
|
280
285
|
async def clientServerInitialized(self, interface_id: str) -> None:
|
|
281
286
|
"""Answer clientServerInitialized with pong."""
|
|
282
287
|
await self.ping(callerId=interface_id)
|
|
@@ -330,7 +335,7 @@ def get_xml_rpc_proxy( # noqa: C901
|
|
|
330
335
|
async def ping(self, callerId: str) -> None:
|
|
331
336
|
"""Answer ping with pong."""
|
|
332
337
|
if self._central:
|
|
333
|
-
await self._central.data_point_event(
|
|
338
|
+
await self._central.event_coordinator.data_point_event(
|
|
334
339
|
interface_id=callerId,
|
|
335
340
|
channel_address="",
|
|
336
341
|
parameter=Parameter.PONG,
|
|
@@ -342,17 +347,17 @@ def get_xml_rpc_proxy( # noqa: C901
|
|
|
342
347
|
) -> None:
|
|
343
348
|
"""Set a paramset."""
|
|
344
349
|
if self._central and paramset_key == ParamsetKey.VALUES:
|
|
345
|
-
interface_id = self._central.primary_client.interface_id # type: ignore[union-attr]
|
|
350
|
+
interface_id = self._central.client_coordinator.primary_client.interface_id # type: ignore[union-attr]
|
|
346
351
|
for param, value in values.items():
|
|
347
|
-
await self._central.data_point_event(
|
|
352
|
+
await self._central.event_coordinator.data_point_event(
|
|
348
353
|
interface_id=interface_id, channel_address=channel_address, parameter=param, value=value
|
|
349
354
|
)
|
|
350
355
|
|
|
351
356
|
async def setValue(self, channel_address: str, parameter: str, value: Any, rx_mode: Any | None = None) -> None:
|
|
352
357
|
"""Set a value."""
|
|
353
358
|
if self._central:
|
|
354
|
-
await self._central.data_point_event(
|
|
355
|
-
interface_id=self._central.primary_client.interface_id, # type: ignore[union-attr]
|
|
359
|
+
await self._central.event_coordinator.data_point_event(
|
|
360
|
+
interface_id=self._central.client_coordinator.primary_client.interface_id, # type: ignore[union-attr]
|
|
356
361
|
channel_address=channel_address,
|
|
357
362
|
parameter=parameter,
|
|
358
363
|
value=value,
|
|
@@ -376,25 +381,175 @@ def get_xml_rpc_proxy( # noqa: C901
|
|
|
376
381
|
return cast(BaseRpcProxy, _AioXmlRpcProxyFromSession())
|
|
377
382
|
|
|
378
383
|
|
|
384
|
+
def _get_instance_attributes(instance: Any) -> set[str]:
|
|
385
|
+
"""
|
|
386
|
+
Get all instance attribute names, supporting both __dict__ and __slots__.
|
|
387
|
+
|
|
388
|
+
For classes with __slots__, iterates through the class hierarchy to collect
|
|
389
|
+
all slot names. For classes with __dict__, returns the keys of __dict__.
|
|
390
|
+
Handles hybrid classes that have both __slots__ and __dict__.
|
|
391
|
+
|
|
392
|
+
Why this is needed:
|
|
393
|
+
Python classes can store instance attributes in two ways:
|
|
394
|
+
1. __dict__: A dictionary attached to each instance (default behavior)
|
|
395
|
+
2. __slots__: Pre-declared attribute names stored more efficiently
|
|
396
|
+
|
|
397
|
+
When copying attributes to a mock, we can't just use instance.__dict__
|
|
398
|
+
because __slots__-based classes don't have __dict__ (or have a limited one).
|
|
399
|
+
We must inspect the class hierarchy to find all declared slots.
|
|
400
|
+
|
|
401
|
+
Algorithm:
|
|
402
|
+
1. Walk the Method Resolution Order (MRO) to find all classes in hierarchy
|
|
403
|
+
2. For each class with __slots__, collect slot names (skip internal ones)
|
|
404
|
+
3. Verify each slot actually has a value on this instance (getattr check)
|
|
405
|
+
4. Also collect any __dict__ attributes if the instance has __dict__
|
|
406
|
+
5. Return the union of all found attribute names
|
|
407
|
+
"""
|
|
408
|
+
attrs: set[str] = set()
|
|
409
|
+
|
|
410
|
+
# Walk the class hierarchy via MRO (Method Resolution Order).
|
|
411
|
+
# __slots__ are inherited but each class defines its own slots separately,
|
|
412
|
+
# so we must check every class in the hierarchy.
|
|
413
|
+
for cls in type(instance).__mro__:
|
|
414
|
+
if hasattr(cls, "__slots__"):
|
|
415
|
+
for slot in cls.__slots__:
|
|
416
|
+
# Skip internal slots like __dict__ and __weakref__ which are
|
|
417
|
+
# automatically added by Python when a class uses __slots__
|
|
418
|
+
if not slot.startswith("__"):
|
|
419
|
+
try:
|
|
420
|
+
# Only include if the attribute actually exists on the instance.
|
|
421
|
+
# Slots can be declared but unset (raises AttributeError).
|
|
422
|
+
getattr(instance, slot)
|
|
423
|
+
attrs.add(slot)
|
|
424
|
+
except AttributeError:
|
|
425
|
+
# Slot is declared but not initialized on this instance
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
# Also include __dict__ attributes if the instance has __dict__.
|
|
429
|
+
# Some classes have both __slots__ and __dict__ (e.g., if a parent class
|
|
430
|
+
# doesn't use __slots__, or if __dict__ is explicitly in __slots__).
|
|
431
|
+
if hasattr(instance, "__dict__"):
|
|
432
|
+
attrs.update(instance.__dict__.keys())
|
|
433
|
+
|
|
434
|
+
return attrs
|
|
435
|
+
|
|
436
|
+
|
|
379
437
|
def get_mock(
|
|
380
438
|
*, instance: Any, exclude_methods: set[str] | None = None, include_properties: set[str] | None = None, **kwargs: Any
|
|
381
439
|
) -> Any:
|
|
382
|
-
"""
|
|
440
|
+
"""
|
|
441
|
+
Create a mock that wraps an instance with proper property delegation.
|
|
442
|
+
|
|
443
|
+
Supports both __dict__-based and __slots__-based classes. Properties are
|
|
444
|
+
delegated dynamically to the wrapped instance to ensure current values
|
|
445
|
+
are always returned.
|
|
446
|
+
|
|
447
|
+
Problem solved:
|
|
448
|
+
MagicMock(wraps=instance) only delegates method calls, not property access.
|
|
449
|
+
When you access mock.some_property, MagicMock returns the value that was
|
|
450
|
+
captured at mock creation time, not the current value on the wrapped instance.
|
|
451
|
+
This causes test failures when the wrapped instance's state changes after
|
|
452
|
+
the mock is created (e.g., client.available changes from False to True
|
|
453
|
+
after initialize_proxy() is called).
|
|
454
|
+
|
|
455
|
+
Solution:
|
|
456
|
+
Create a dynamic MagicMock subclass with property descriptors that delegate
|
|
457
|
+
to the wrapped instance on every access. This ensures properties always
|
|
458
|
+
return current values.
|
|
459
|
+
|
|
460
|
+
Algorithm:
|
|
461
|
+
1. If already a Mock, just sync attributes from wrapped instance
|
|
462
|
+
2. Identify all properties on the instance's class
|
|
463
|
+
3. Create a MagicMock subclass with delegating property descriptors
|
|
464
|
+
4. Create mock instance with spec and wraps
|
|
465
|
+
5. Copy instance attributes (supports both __slots__ and __dict__)
|
|
466
|
+
6. Copy non-mockable methods directly to mock
|
|
467
|
+
"""
|
|
383
468
|
if exclude_methods is None:
|
|
384
469
|
exclude_methods = set()
|
|
385
470
|
if include_properties is None:
|
|
386
471
|
include_properties = set()
|
|
387
472
|
|
|
473
|
+
# Early return: if already a Mock, just refresh attributes from wrapped instance
|
|
388
474
|
if isinstance(instance, Mock):
|
|
389
|
-
|
|
475
|
+
if hasattr(instance, "_mock_wraps") and instance._mock_wraps is not None:
|
|
476
|
+
for attr in _get_instance_attributes(instance._mock_wraps):
|
|
477
|
+
with contextlib.suppress(AttributeError, TypeError):
|
|
478
|
+
setattr(instance, attr, getattr(instance._mock_wraps, attr))
|
|
390
479
|
return instance
|
|
391
|
-
|
|
392
|
-
|
|
480
|
+
|
|
481
|
+
# Step 1: Identify all @property decorated attributes on the class
|
|
482
|
+
# These need special handling because MagicMock doesn't delegate property access
|
|
483
|
+
property_names = _get_properties(data_object=instance, decorator=property)
|
|
484
|
+
|
|
485
|
+
# Step 2: Create a dynamic MagicMock subclass
|
|
486
|
+
# We add property descriptors to this subclass that delegate to _mock_wraps.
|
|
487
|
+
# This is the key technique: property descriptors on the class take precedence
|
|
488
|
+
# over MagicMock's attribute access, allowing us to intercept property reads.
|
|
489
|
+
class _DynamicMock(MagicMock):
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
# Helper factory functions to create closures with correct name binding.
|
|
493
|
+
# Using a factory function ensures each property gets its own 'name' variable,
|
|
494
|
+
# avoiding the classic lambda closure bug where all properties would share
|
|
495
|
+
# the last loop value.
|
|
496
|
+
def _make_getter(name: str) -> Callable[[Any], Any]:
|
|
497
|
+
"""Create a getter that delegates to the wrapped instance."""
|
|
498
|
+
|
|
499
|
+
def getter(self: Any) -> Any:
|
|
500
|
+
# Access _mock_wraps which holds the original instance
|
|
501
|
+
return getattr(self._mock_wraps, name)
|
|
502
|
+
|
|
503
|
+
return getter
|
|
504
|
+
|
|
505
|
+
def _make_setter(name: str) -> Callable[[Any, Any], None]:
|
|
506
|
+
"""Create a setter that delegates to the wrapped instance."""
|
|
507
|
+
|
|
508
|
+
def setter(self: Any, value: Any) -> None:
|
|
509
|
+
setattr(self._mock_wraps, name, value)
|
|
510
|
+
|
|
511
|
+
return setter
|
|
512
|
+
|
|
513
|
+
# Step 3: Add property descriptors to the dynamic subclass
|
|
514
|
+
for prop_name in property_names:
|
|
515
|
+
# Skip properties that should be mocked or overridden via kwargs
|
|
516
|
+
if prop_name not in include_properties and prop_name not in kwargs:
|
|
517
|
+
# Check if the original property has a setter (is writable)
|
|
518
|
+
prop_descriptor = getattr(type(instance), prop_name, None)
|
|
519
|
+
if prop_descriptor is not None and getattr(prop_descriptor, "fset", None) is not None:
|
|
520
|
+
# Writable property: create descriptor with both getter and setter
|
|
521
|
+
setattr(
|
|
522
|
+
_DynamicMock,
|
|
523
|
+
prop_name,
|
|
524
|
+
property(_make_getter(prop_name), _make_setter(prop_name)),
|
|
525
|
+
)
|
|
526
|
+
else:
|
|
527
|
+
# Read-only property: create descriptor with getter only
|
|
528
|
+
setattr(
|
|
529
|
+
_DynamicMock,
|
|
530
|
+
prop_name,
|
|
531
|
+
property(_make_getter(prop_name)),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Step 4: Create the mock instance
|
|
535
|
+
# spec=instance: ensures mock only allows access to attributes that exist on instance
|
|
536
|
+
# wraps=instance: delegates method calls to the real instance
|
|
537
|
+
mock = _DynamicMock(spec=instance, wraps=instance, **kwargs)
|
|
538
|
+
|
|
539
|
+
# Step 5: Copy instance attributes to mock
|
|
540
|
+
# This handles both __slots__ and __dict__ based classes via _get_instance_attributes()
|
|
541
|
+
for attr in _get_instance_attributes(instance):
|
|
542
|
+
with contextlib.suppress(AttributeError, TypeError):
|
|
543
|
+
setattr(mock, attr, getattr(instance, attr))
|
|
544
|
+
|
|
545
|
+
# Step 6: Copy non-mockable methods directly
|
|
546
|
+
# Some methods (like bound methods or special attributes) need to be copied
|
|
547
|
+
# directly rather than being mocked
|
|
393
548
|
try:
|
|
394
549
|
for method_name in [
|
|
395
550
|
prop
|
|
396
551
|
for prop in _get_not_mockable_method_names(instance=instance, exclude_methods=exclude_methods)
|
|
397
|
-
if prop not in include_properties and prop not in kwargs
|
|
552
|
+
if prop not in include_properties and prop not in kwargs and prop not in property_names
|
|
398
553
|
]:
|
|
399
554
|
setattr(mock, method_name, getattr(instance, method_name))
|
|
400
555
|
except Exception:
|
|
@@ -513,7 +668,7 @@ class SessionPlayer:
|
|
|
513
668
|
return None
|
|
514
669
|
if not (bucket_by_parameter := bucket_by_method.get(method)):
|
|
515
670
|
return None
|
|
516
|
-
frozen_params = _freeze_params(params=params)
|
|
671
|
+
frozen_params = _freeze_params(params=_cleanup_params_for_session(params=params))
|
|
517
672
|
|
|
518
673
|
# For each parameter, choose the response at the latest timestamp.
|
|
519
674
|
if (bucket_by_ts := bucket_by_parameter.get(frozen_params)) is None:
|
|
Binary file
|
|
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
|