aiohomematic 2025.9.1__py3-none-any.whl → 2025.9.3__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.

Potentially problematic release.


This version of aiohomematic might be problematic. Click here for more details.

Files changed (55) hide show
  1. aiohomematic/caches/dynamic.py +1 -6
  2. aiohomematic/central/__init__.py +34 -23
  3. aiohomematic/central/xml_rpc_server.py +1 -1
  4. aiohomematic/client/__init__.py +35 -29
  5. aiohomematic/client/json_rpc.py +44 -12
  6. aiohomematic/client/xml_rpc.py +53 -20
  7. aiohomematic/const.py +2 -2
  8. aiohomematic/decorators.py +56 -21
  9. aiohomematic/model/__init__.py +1 -1
  10. aiohomematic/model/calculated/__init__.py +1 -1
  11. aiohomematic/model/calculated/climate.py +1 -1
  12. aiohomematic/model/calculated/data_point.py +3 -3
  13. aiohomematic/model/calculated/operating_voltage_level.py +7 -21
  14. aiohomematic/model/calculated/support.py +20 -0
  15. aiohomematic/model/custom/__init__.py +1 -1
  16. aiohomematic/model/custom/climate.py +18 -18
  17. aiohomematic/model/custom/cover.py +1 -1
  18. aiohomematic/model/custom/data_point.py +1 -1
  19. aiohomematic/model/custom/light.py +1 -1
  20. aiohomematic/model/custom/lock.py +1 -1
  21. aiohomematic/model/custom/siren.py +1 -1
  22. aiohomematic/model/custom/switch.py +1 -1
  23. aiohomematic/model/custom/valve.py +1 -1
  24. aiohomematic/model/data_point.py +24 -24
  25. aiohomematic/model/device.py +22 -21
  26. aiohomematic/model/event.py +3 -8
  27. aiohomematic/model/generic/__init__.py +1 -1
  28. aiohomematic/model/generic/binary_sensor.py +1 -1
  29. aiohomematic/model/generic/button.py +1 -1
  30. aiohomematic/model/generic/data_point.py +4 -6
  31. aiohomematic/model/generic/number.py +1 -1
  32. aiohomematic/model/generic/select.py +1 -1
  33. aiohomematic/model/generic/sensor.py +1 -1
  34. aiohomematic/model/generic/switch.py +4 -4
  35. aiohomematic/model/generic/text.py +1 -1
  36. aiohomematic/model/hub/binary_sensor.py +1 -1
  37. aiohomematic/model/hub/button.py +2 -2
  38. aiohomematic/model/hub/data_point.py +4 -7
  39. aiohomematic/model/hub/number.py +1 -1
  40. aiohomematic/model/hub/select.py +2 -2
  41. aiohomematic/model/hub/sensor.py +1 -1
  42. aiohomematic/model/hub/switch.py +3 -3
  43. aiohomematic/model/hub/text.py +1 -1
  44. aiohomematic/model/support.py +1 -40
  45. aiohomematic/model/update.py +5 -4
  46. aiohomematic/property_decorators.py +511 -0
  47. aiohomematic/support.py +71 -85
  48. {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.3.dist-info}/METADATA +7 -5
  49. aiohomematic-2025.9.3.dist-info/RECORD +78 -0
  50. aiohomematic_support/client_local.py +5 -5
  51. aiohomematic/model/decorators.py +0 -194
  52. aiohomematic-2025.9.1.dist-info/RECORD +0 -78
  53. {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.3.dist-info}/WHEEL +0 -0
  54. {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.3.dist-info}/licenses/LICENSE +0 -0
  55. {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.3.dist-info}/top_level.txt +0 -0
@@ -7,9 +7,9 @@ from __future__ import annotations
7
7
  from typing import cast
8
8
 
9
9
  from aiohomematic.const import DataPointCategory
10
- from aiohomematic.model.decorators import state_property
11
10
  from aiohomematic.model.hub.data_point import GenericSysvarDataPoint
12
11
  from aiohomematic.model.support import check_length_and_log
12
+ from aiohomematic.property_decorators import state_property
13
13
 
14
14
 
15
15
  class SysvarDpText(GenericSysvarDataPoint):
@@ -33,18 +33,12 @@ from aiohomematic.const import (
33
33
  )
34
34
  from aiohomematic.model import device as hmd
35
35
  from aiohomematic.model.custom import definition as hmed
36
- from aiohomematic.model.decorators import (
37
- get_public_attributes_for_config_property,
38
- get_public_attributes_for_info_property,
39
- get_public_attributes_for_state_property,
40
- )
41
36
  from aiohomematic.support import to_bool
42
37
 
43
38
  __all__ = [
44
39
  "ChannelNameData",
45
40
  "DataPointNameData",
46
41
  "GenericParameterType",
47
- "PayloadMixin",
48
42
  "check_channel_is_the_only_primary_channel",
49
43
  "convert_value",
50
44
  "generate_channel_unique_id",
@@ -70,39 +64,6 @@ _BINARY_SENSOR_TRUE_VALUE_DICT_FOR_VALUE_LIST: Final[Mapping[tuple[str, ...], st
70
64
  }
71
65
 
72
66
 
73
- class PayloadMixin:
74
- """Mixin to add payload methods to class."""
75
-
76
- __slots__ = ()
77
-
78
- @property
79
- def config_payload(self) -> Mapping[str, Any]:
80
- """Return the config payload."""
81
- return {
82
- key: value
83
- for key, value in get_public_attributes_for_config_property(data_object=self).items()
84
- if value is not None
85
- }
86
-
87
- @property
88
- def info_payload(self) -> Mapping[str, Any]:
89
- """Return the info payload."""
90
- return {
91
- key: value
92
- for key, value in get_public_attributes_for_info_property(data_object=self).items()
93
- if value is not None
94
- }
95
-
96
- @property
97
- def state_payload(self) -> Mapping[str, Any]:
98
- """Return the state payload."""
99
- return {
100
- key: value
101
- for key, value in get_public_attributes_for_state_property(data_object=self).items()
102
- if value is not None
103
- }
104
-
105
-
106
67
  class ChannelNameData:
107
68
  """Dataclass for channel name parts."""
108
69
 
@@ -618,7 +579,7 @@ def get_index_of_value_from_value_list(
618
579
  value: SYSVAR_TYPE, value_list: tuple[str, ...] | list[str] | None
619
580
  ) -> int | None:
620
581
  """Check if value is in value list."""
621
- if value is not None and isinstance(value, (str, StrEnum)) and value_list is not None and value in value_list:
582
+ if value is not None and isinstance(value, str | StrEnum) and value_list is not None and value in value_list:
622
583
  return value_list.index(value)
623
584
 
624
585
  return None
@@ -21,8 +21,9 @@ from aiohomematic.decorators import inspector
21
21
  from aiohomematic.exceptions import AioHomematicException
22
22
  from aiohomematic.model import device as hmd
23
23
  from aiohomematic.model.data_point import CallbackDataPoint
24
- from aiohomematic.model.decorators import config_property, state_property
25
- from aiohomematic.model.support import DataPointPathData, PayloadMixin, generate_unique_id
24
+ from aiohomematic.model.support import DataPointPathData, generate_unique_id
25
+ from aiohomematic.property_decorators import config_property, state_property
26
+ from aiohomematic.support import PayloadMixin
26
27
 
27
28
  __all__ = ["DpUpdate"]
28
29
 
@@ -130,12 +131,12 @@ class DpUpdate(CallbackDataPoint, PayloadMixin):
130
131
  self._custom_id = None
131
132
  self._device.unregister_firmware_update_callback(cb)
132
133
 
133
- @inspector()
134
+ @inspector
134
135
  async def update_firmware(self, refresh_after_update_intervals: tuple[int, ...]) -> bool:
135
136
  """Turn the update on."""
136
137
  return await self._device.update_firmware(refresh_after_update_intervals=refresh_after_update_intervals)
137
138
 
138
- @inspector()
139
+ @inspector
139
140
  async def refresh_firmware_data(self) -> None:
140
141
  """Refresh device firmware data."""
141
142
  await self._device.central.refresh_firmware_data(device_address=self._device.address)
@@ -0,0 +1,511 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Decorators and helpers for declaring public attributes on data point classes.
5
+
6
+ This module provides four decorator factories that behave like the built-in
7
+ @property, but additionally annotate properties with a semantic category so they
8
+ can be automatically collected to build payloads and log contexts:
9
+ - cached_property: computed once per instance and cached until the value is
10
+ invalidated by a setter/deleter on the same descriptor.
11
+ - config_property: configuration-related properties.
12
+ - info_property: informational/metadata properties.
13
+ - state_property: dynamic state properties.
14
+
15
+ All decorators accept an optional keyword-only argument log_context. If set to
16
+ True, the property will be included in the LogContextMixin.log_context mapping.
17
+
18
+ Notes on caching
19
+ - cached_property always caches on first access and invalidates on set/delete.
20
+ - The other decorators can be created with cached=True to enable the same
21
+ behavior when desired.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Callable, Mapping
27
+ from datetime import datetime
28
+ from enum import Enum, StrEnum
29
+ from typing import Any, Final, ParamSpec, TypeVar, cast, overload
30
+ from weakref import WeakKeyDictionary
31
+
32
+ from aiohomematic import support as hms
33
+
34
+ __all__ = [
35
+ "config_property",
36
+ "get_hm_property_by_kind",
37
+ "info_property",
38
+ "state_property",
39
+ ]
40
+
41
+ P = ParamSpec("P")
42
+ T = TypeVar("T")
43
+ R = TypeVar("R")
44
+
45
+
46
+ class Kind(StrEnum):
47
+ """Enum for property feature flags."""
48
+
49
+ CONFIG = "config"
50
+ INFO = "info"
51
+ SIMPLE = "simple"
52
+ STATE = "state"
53
+
54
+
55
+ class _GenericProperty[GETTER, SETTER](property):
56
+ """
57
+ Base descriptor used by all property decorators in this module.
58
+
59
+ Extends the built-in property to optionally cache the computed value on the
60
+ instance and to carry a log_context flag.
61
+
62
+ Args:
63
+ - fget/fset/fdel: Standard property callables.
64
+ - doc: Optional docstring of the property.
65
+ - cached: If True, the computed value is cached per instance and
66
+ invalidated when the descriptor receives a set/delete.
67
+ - log_context: If True, the property is included in get_attributes_for_log_context().
68
+
69
+ """
70
+
71
+ fget: Callable[[Any], GETTER] | None
72
+ fset: Callable[[Any, SETTER], None] | None
73
+ fdel: Callable[[Any], None] | None
74
+
75
+ def __init__(
76
+ self,
77
+ fget: Callable[[Any], GETTER] | None = None,
78
+ fset: Callable[[Any, SETTER], None] | None = None,
79
+ fdel: Callable[[Any], None] | None = None,
80
+ doc: str | None = None,
81
+ kind: Kind = Kind.SIMPLE,
82
+ cached: bool = False,
83
+ log_context: bool = False,
84
+ ) -> None:
85
+ """
86
+ Initialize the descriptor.
87
+
88
+ Mirrors the standard property signature and adds two options:
89
+ - kind: specify the kind of property (e.g. simple, cached, config, info, state).
90
+ - cached: enable per-instance caching of the computed value.
91
+ - log_context: mark this property as relevant for structured logging.
92
+ """
93
+ super().__init__(fget, fset, fdel, doc)
94
+ if doc is None and fget is not None:
95
+ doc = fget.__doc__
96
+ self.__doc__ = doc
97
+ self.kind: Final = kind
98
+ self._cached: Final = cached
99
+ self.log_context = log_context
100
+ if cached:
101
+ if fget is not None:
102
+ func_name = fget.__name__
103
+ elif fset is not None:
104
+ func_name = fset.__name__
105
+ elif fdel is not None:
106
+ func_name = fdel.__name__
107
+ else:
108
+ func_name = "prop"
109
+ self._cache_attr = f"_cached_{func_name}" # Default name of the cache attribute
110
+
111
+ def getter(self, fget: Callable[[Any], GETTER], /) -> _GenericProperty:
112
+ """Return generic getter."""
113
+ return type(self)(
114
+ fget=fget,
115
+ fset=self.fset,
116
+ fdel=self.fdel,
117
+ doc=self.__doc__,
118
+ kind=self.kind,
119
+ cached=self._cached,
120
+ log_context=self.log_context,
121
+ ) # pragma: no cover
122
+
123
+ def setter(self, fset: Callable[[Any, SETTER], None], /) -> _GenericProperty:
124
+ """Return generic setter."""
125
+ return type(self)(
126
+ fget=self.fget,
127
+ fset=fset,
128
+ fdel=self.fdel,
129
+ doc=self.__doc__,
130
+ kind=self.kind,
131
+ cached=self._cached,
132
+ log_context=self.log_context,
133
+ )
134
+
135
+ def deleter(self, fdel: Callable[[Any], None], /) -> _GenericProperty:
136
+ """Return generic deleter."""
137
+ return type(self)(
138
+ fget=self.fget,
139
+ fset=self.fset,
140
+ fdel=fdel,
141
+ doc=self.__doc__,
142
+ kind=self.kind,
143
+ cached=self._cached,
144
+ log_context=self.log_context,
145
+ )
146
+
147
+ def __get__(self, instance: Any, gtype: type | None = None, /) -> GETTER: # type: ignore[override]
148
+ """
149
+ Return the attribute value.
150
+
151
+ If caching is enabled, compute on first access and return the per-instance
152
+ cached value on subsequent accesses.
153
+ """
154
+ if instance is None:
155
+ # Accessed from class, return the descriptor itself
156
+ return cast(GETTER, self)
157
+ if self.fget is None:
158
+ raise AttributeError("unreadable attribute") # pragma: no cover
159
+
160
+ if not self._cached:
161
+ return self.fget(instance)
162
+
163
+ # If the cached value is not set yet, compute and store it
164
+ if not hasattr(instance, self._cache_attr):
165
+ value = self.fget(instance)
166
+ setattr(instance, self._cache_attr, value)
167
+
168
+ # Return the cached value
169
+ return cast(GETTER, getattr(instance, self._cache_attr))
170
+
171
+ def __set__(self, instance: Any, value: Any, /) -> None:
172
+ """Set the attribute value and invalidate cache if enabled."""
173
+ # Delete the cached value so it can be recomputed on next access.
174
+ if self._cached and hasattr(instance, self._cache_attr):
175
+ delattr(instance, self._cache_attr)
176
+
177
+ if self.fset is None:
178
+ raise AttributeError("can't set attribute") # pragma: no cover
179
+ self.fset(instance, value)
180
+
181
+ def __delete__(self, instance: Any, /) -> None:
182
+ """Delete the attribute and invalidate cache if enabled."""
183
+
184
+ # Delete the cached value so it can be recomputed on next access.
185
+ if self._cached and hasattr(instance, self._cache_attr):
186
+ delattr(instance, self._cache_attr)
187
+
188
+ if self.fdel is None:
189
+ raise AttributeError("can't delete attribute") # pragma: no cover
190
+ self.fdel(instance)
191
+
192
+
193
+ # ----- hm_property -----
194
+
195
+
196
+ @overload
197
+ def hm_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
198
+
199
+
200
+ @overload
201
+ def hm_property(
202
+ *, kind: Kind = ..., cached: bool = ..., log_context: bool = ...
203
+ ) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
204
+
205
+
206
+ def hm_property[PR](
207
+ func: Callable[[Any], PR] | None = None,
208
+ *,
209
+ kind: Kind = Kind.SIMPLE,
210
+ cached: bool = False,
211
+ log_context: bool = False,
212
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
213
+ """
214
+ Decorate a method as a computed attribute.
215
+
216
+ Supports both usages:
217
+ - @hm_property
218
+ - @hm_property(kind=..., cached=True, log_context=True)
219
+
220
+ Args:
221
+ func: The function being decorated when used as @hm_property without
222
+ parentheses. When used as a factory (i.e., @hm_property(...)), this
223
+ is None and the returned callable expects the function to decorate.
224
+ kind: Specify the kind of property (e.g. simple, config, info, state).
225
+ cached: Optionally enable per-instance caching for this property.
226
+ log_context: Include this property in structured log context if True.
227
+
228
+ """
229
+ if func is None:
230
+
231
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
232
+ return _GenericProperty(f, kind=kind, cached=cached, log_context=log_context)
233
+
234
+ return wrapper
235
+ return _GenericProperty(func, kind=kind, cached=cached, log_context=log_context)
236
+
237
+
238
+ # ----- cached_property -----
239
+
240
+
241
+ @overload
242
+ def cached_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
243
+
244
+
245
+ @overload
246
+ def cached_property(*, log_context: bool = ...) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
247
+
248
+
249
+ def cached_property[PR](
250
+ func: Callable[[Any], PR] | None = None,
251
+ *,
252
+ log_context: bool = False,
253
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
254
+ """
255
+ Decorate a method as a computed attribute with per-instance caching.
256
+
257
+ Supports both usages:
258
+ - @cached_property
259
+ - @cached_property(log_context=True)
260
+
261
+ Args:
262
+ func: The function being decorated when used as @cached_property without
263
+ parentheses. When used as a factory (i.e., @cached_property(...)), this
264
+ is None and the returned callable expects the function to decorate.
265
+ log_context: Include this property in structured log context if True.
266
+
267
+ """
268
+ if func is None:
269
+
270
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
271
+ return _GenericProperty(f, kind=Kind.SIMPLE, cached=True, log_context=log_context)
272
+
273
+ return wrapper
274
+ return _GenericProperty(func, kind=Kind.SIMPLE, cached=True, log_context=log_context)
275
+
276
+
277
+ # ----- config_property -----
278
+
279
+
280
+ @overload
281
+ def config_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
282
+
283
+
284
+ @overload
285
+ def config_property(
286
+ *, cached: bool = ..., log_context: bool = ...
287
+ ) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
288
+
289
+
290
+ def config_property[PR](
291
+ func: Callable[[Any], PR] | None = None,
292
+ *,
293
+ cached: bool = False,
294
+ log_context: bool = False,
295
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
296
+ """
297
+ Decorate a method as a configuration property.
298
+
299
+ Supports both usages:
300
+ - @config_property
301
+ - @config_property(cached=True, log_context=True)
302
+
303
+ Args:
304
+ func: The function being decorated when used as @config_property without
305
+ parentheses. When used as a factory (i.e., @config_property(...)), this is
306
+ None and the returned callable expects the function to decorate.
307
+ cached: Enable per-instance caching for this property when True.
308
+ log_context: Include this property in structured log context if True.
309
+
310
+ """
311
+ if func is None:
312
+
313
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
314
+ return _GenericProperty(f, kind=Kind.CONFIG, cached=cached, log_context=log_context)
315
+
316
+ return wrapper
317
+ return _GenericProperty(func, kind=Kind.CONFIG, cached=cached, log_context=log_context)
318
+
319
+
320
+ # ----- info_property -----
321
+
322
+
323
+ @overload
324
+ def info_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
325
+
326
+
327
+ @overload
328
+ def info_property(
329
+ *, cached: bool = ..., log_context: bool = ...
330
+ ) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
331
+
332
+
333
+ def info_property[PR](
334
+ func: Callable[[Any], PR] | None = None,
335
+ *,
336
+ cached: bool = False,
337
+ log_context: bool = False,
338
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
339
+ """
340
+ Decorate a method as an informational/metadata property.
341
+
342
+ Supports both usages:
343
+ - @info_property
344
+ - @info_property(cached=True, log_context=True)
345
+
346
+ Args:
347
+ func: The function being decorated when used as @info_property without
348
+ parentheses. When used as a factory (i.e., @info_property(...)), this is
349
+ None and the returned callable expects the function to decorate.
350
+ cached: Enable per-instance caching for this property when True.
351
+ log_context: Include this property in structured log context if True.
352
+
353
+ """
354
+ if func is None:
355
+
356
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
357
+ return _GenericProperty(f, kind=Kind.INFO, cached=cached, log_context=log_context)
358
+
359
+ return wrapper
360
+ return _GenericProperty(func, kind=Kind.INFO, cached=cached, log_context=log_context)
361
+
362
+
363
+ # ----- state_property -----
364
+
365
+
366
+ @overload
367
+ def state_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
368
+
369
+
370
+ @overload
371
+ def state_property(
372
+ *, cached: bool = ..., log_context: bool = ...
373
+ ) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
374
+
375
+
376
+ def state_property[PR](
377
+ func: Callable[[Any], PR] | None = None,
378
+ *,
379
+ cached: bool = False,
380
+ log_context: bool = False,
381
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
382
+ """
383
+ Decorate a method as a dynamic state property.
384
+
385
+ Supports both usages:
386
+ - @state_property
387
+ - @state_property(cached=True, log_context=True)
388
+
389
+ Args:
390
+ func: The function being decorated when used as @state_property without
391
+ parentheses. When used as a factory (i.e., @state_property(...)), this is
392
+ None and the returned callable expects the function to decorate.
393
+ cached: Enable per-instance caching for this property when True.
394
+ log_context: Include this property in structured log context if True.
395
+
396
+ """
397
+ if func is None:
398
+
399
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
400
+ return _GenericProperty(f, kind=Kind.STATE, cached=cached, log_context=log_context)
401
+
402
+ return wrapper
403
+ return _GenericProperty(func, kind=Kind.STATE, cached=cached, log_context=log_context)
404
+
405
+
406
+ # ----------
407
+
408
+
409
+ # Cache for per-class attribute names by decorator to avoid repeated dir() scans
410
+ # Use WeakKeyDictionary to allow classes to be garbage-collected without leaking cache entries.
411
+ # Structure: {cls: {decorator_class: (attr_name1, attr_name2, ...)}}
412
+ _PUBLIC_ATTR_CACHE: WeakKeyDictionary[type, dict[Kind, tuple[str, ...]]] = WeakKeyDictionary()
413
+
414
+
415
+ def get_hm_property_by_kind(data_object: Any, kind: Kind, context: bool = False) -> Mapping[str, Any]:
416
+ """
417
+ Collect properties from an object that are defined using a specific decorator.
418
+
419
+ Args:
420
+ data_object: The instance to inspect.
421
+ kind: The decorator class to use for filtering.
422
+ context: If True, only include properties where the descriptor has
423
+ log_context=True. When such a property's value is a LogContextMixin, its
424
+ items are flattened into the result using a short prefix of the property
425
+ name (e.g. "p.key").
426
+
427
+ Returns:
428
+ Mapping[str, Any]: A mapping of attribute name to normalized value. Values are converted via
429
+ _get_text_value() to provide stable JSON/log-friendly types.
430
+
431
+ Notes:
432
+ Attribute NAMES are cached per (class, decorator) to avoid repeated dir()
433
+ scans. Values are never cached here since they are instance-dependent.
434
+ Getter exceptions are swallowed and represented as None so payload building
435
+ remains robust and side-effect free.
436
+
437
+ """
438
+ cls = data_object.__class__
439
+
440
+ # Get or create the per-class cache dict
441
+ if (decorator_cache := _PUBLIC_ATTR_CACHE.get(cls)) is None:
442
+ decorator_cache = {}
443
+ _PUBLIC_ATTR_CACHE[cls] = decorator_cache
444
+
445
+ if (names := decorator_cache.get(kind)) is None:
446
+ names = tuple(
447
+ y for y in dir(cls) if (gp := getattr(cls, y)) and isinstance(gp, _GenericProperty) and gp.kind == kind
448
+ )
449
+ decorator_cache[kind] = names
450
+
451
+ result: dict[str, Any] = {}
452
+ for name in names:
453
+ if context and getattr(cls, name).log_context is False:
454
+ continue
455
+ try:
456
+ value = getattr(data_object, name)
457
+ if isinstance(value, hms.LogContextMixin):
458
+ result.update({f"{name[:1]}.{k}": v for k, v in value.log_context.items()})
459
+ else:
460
+ result[name] = _get_text_value(value)
461
+ except Exception:
462
+ # Avoid propagating side effects/errors from getters
463
+ result[name] = None
464
+ return result
465
+
466
+
467
+ def _get_text_value(value: Any) -> Any:
468
+ """
469
+ Normalize values for payload/logging purposes.
470
+
471
+ - list/tuple/set are converted to tuples and their items normalized recursively
472
+ - Enum values are converted to their string representation
473
+ - datetime objects are converted to unix timestamps (float)
474
+ - all other types are returned unchanged
475
+
476
+ Args:
477
+ value: The input value to normalize into a log-/JSON-friendly representation.
478
+
479
+ Returns:
480
+ Any: The normalized value, potentially converted as described above.
481
+
482
+ """
483
+ if isinstance(value, list | tuple | set):
484
+ return tuple(_get_text_value(v) for v in value)
485
+ if isinstance(value, Enum):
486
+ return str(value)
487
+ if isinstance(value, datetime):
488
+ return datetime.timestamp(value)
489
+ return value
490
+
491
+
492
+ def get_hm_property_by_log_context(data_object: Any) -> Mapping[str, Any]:
493
+ """
494
+ Return combined log context attributes across all property categories.
495
+
496
+ Includes only properties declared with log_context=True and flattens
497
+ values that implement LogContextMixin by prefixing with a short key.
498
+
499
+ Args:
500
+ data_object: The instance from which to collect attributes marked for
501
+ log context across all property categories.
502
+
503
+ Returns:
504
+ Mapping[str, Any]: A mapping of attribute name to normalized value for logging.
505
+
506
+ """
507
+ result: dict[str, Any] = {}
508
+ for kind in Kind:
509
+ result.update(get_hm_property_by_kind(data_object=data_object, kind=kind, context=True))
510
+
511
+ return result