pyg90alarm 2.4.2__py3-none-any.whl → 2.5.1__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.
pyg90alarm/__init__.py CHANGED
@@ -52,6 +52,11 @@ from .local.host_config import (
52
52
  from .local.alarm_phones import G90AlarmPhones
53
53
  from .local.net_config import G90NetConfig, G90APNAuth
54
54
  from .local.history import G90History
55
+
56
+ from .dataclass.load_save import DataclassLoadSave
57
+ from .dataclass.validation import (
58
+ get_field_validation_constraints,
59
+ )
55
60
  from .const import (
56
61
  G90MessageTypes,
57
62
  G90NotificationTypes,
@@ -95,4 +100,8 @@ __all__ = [
95
100
  'G90AlarmPhones',
96
101
  # History
97
102
  'G90History',
103
+ # Dataclass validation
104
+ 'get_field_validation_constraints',
105
+ # Dataclass load/save
106
+ 'DataclassLoadSave',
98
107
  ]
pyg90alarm/const.py CHANGED
@@ -39,6 +39,8 @@ ROOM_ID = 0
39
39
 
40
40
  CMD_PAGE_SIZE = 10
41
41
 
42
+ BUG_REPORT_URL = 'https://github.com/hostcc/pyg90alarm/issues'
43
+
42
44
 
43
45
  class G90Commands(IntEnum):
44
46
  """
File without changes
@@ -22,16 +22,106 @@
22
22
  Base class for loading/saving dataclasses to a device.
23
23
  """
24
24
  from __future__ import annotations
25
- from typing import TYPE_CHECKING, Type, TypeVar, Optional, ClassVar, Any, Dict
25
+ from typing import (
26
+ TYPE_CHECKING, Type, TypeVar, Optional, ClassVar, Any, Dict, List, cast
27
+ )
26
28
  import logging
27
- from dataclasses import dataclass, astuple, asdict
29
+ from dataclasses import dataclass, asdict, field, fields
30
+ from .validation import ValidatorBase
28
31
  from ..const import G90Commands
29
32
  if TYPE_CHECKING:
30
33
  from ..alarm import G90Alarm
31
34
 
32
35
 
33
36
  _LOGGER = logging.getLogger(__name__)
34
- S = TypeVar('S', bound='DataclassLoadSave')
37
+ DataclassLoadSaveT = TypeVar('DataclassLoadSaveT', bound='DataclassLoadSave')
38
+ T = TypeVar('T')
39
+
40
+
41
+ class Metadata:
42
+ """
43
+ Metadata keys for DataclassLoadSave fields.
44
+ """
45
+ # pylint: disable=too-few-public-methods
46
+ NO_SERIALIZE = 'no_serialize'
47
+ SKIP_NONE = 'skip_none'
48
+
49
+
50
+ class ReadOnlyIfNotProvided(ValidatorBase[T]):
51
+ """
52
+ Descriptor for dataclass fields to be read-only if not provided during
53
+ initialization.
54
+
55
+ The field can be read, but attempts to modify it will raise a
56
+ ValueError if the field was not provided during initialization. In
57
+ other words, the only way to set the value is during object creation.
58
+
59
+ Example usage:
60
+
61
+ @dataclass
62
+ class Example:
63
+ read_only_field: Optional[int] = field_readonly_if_not_provided(
64
+ default=None
65
+ )
66
+
67
+ # Works ok
68
+ ex = Example(read_only_field=42)
69
+ print(ex.read_only_field) # Outputs: 42
70
+ ex.read_only_field = 100 # Works ok
71
+
72
+ # Raises ValueError
73
+ ex2 = Example()
74
+ print(ex2.read_only_field) # Outputs: None
75
+ ex2.read_only_field = 100 # Raises ValueError
76
+
77
+ :param default: Default value to return upon read if not provided during
78
+ initialization.
79
+ """
80
+ # pylint: disable=too-few-public-methods
81
+
82
+ def __validate__(self, obj: Any, value: T) -> bool:
83
+ """
84
+ Validation method.
85
+ """
86
+ # Prevent setting the value if it was not provided during
87
+ # initialization. The condition is determined by checking if the
88
+ # current value is `self` - i.e. the descriptor instance hasn't been
89
+ # replaced with an actual value
90
+ if getattr(obj, self.__field_name__, self._default) is self:
91
+ raise ValueError(
92
+ f'Field {self.__unmangled_name__} is read-only because'
93
+ ' it was not provided during initialization'
94
+ )
95
+ return True
96
+
97
+
98
+ def field_readonly_if_not_provided(
99
+ *args: Any, default: Optional[T] = None, **kwargs: Any
100
+ ) -> T:
101
+ """
102
+ Helper function to create a dataclass field with ReadOnlyIfNotProvided
103
+ descriptor.
104
+
105
+ :param args: Positional arguments to pass to `dataclasses.field()`.
106
+ :param default: Default value to return upon read if not provided during
107
+ initialization.
108
+ :param kwargs: Keyword arguments to pass to `dataclasses.field()`.
109
+ :return: A dataclass field with ReadOnlyIfNotProvided descriptor attached.
110
+ """
111
+ # Also set SKIP_NONE metadata if default is None, so that the field is
112
+ # skipped during serialization when its value is None
113
+ if default is None:
114
+ if 'metadata' not in kwargs:
115
+ kwargs['metadata'] = {}
116
+ kwargs['metadata'][Metadata.SKIP_NONE] = True
117
+
118
+ # Instantiate the field with ReadOnlyIfNotProvided descriptor and rest of
119
+ # the provided arguments
120
+ # pylint: disable=invalid-field-call
121
+ return cast(T, field(
122
+ *args, **kwargs,
123
+ default=ReadOnlyIfNotProvided[T](default)
124
+ ))
35
125
 
36
126
 
37
127
  @dataclass
@@ -79,6 +169,34 @@ class DataclassLoadSave:
79
169
  # declared here to avoid being part of dataclass fields
80
170
  self._parent: Optional[G90Alarm] = None
81
171
 
172
+ def serialize(self) -> List[Any]:
173
+ """
174
+ Returns the dataclass fields as a list.
175
+
176
+ Handles specific metadata for the fields.
177
+ :seealso:`Metadata`.
178
+
179
+ :return: Dataclass serialized as list.
180
+ """
181
+ result = []
182
+
183
+ for f in fields(self):
184
+ # Skip fields marked with NO_SERIALIZE metadata
185
+ if f.metadata.get(Metadata.NO_SERIALIZE, False):
186
+ continue
187
+
188
+ # Skip fields with None value if SKIP_NONE metadata is set
189
+ if (
190
+ f.metadata.get(Metadata.SKIP_NONE, False)
191
+ and getattr(self, f.name) is None
192
+ ):
193
+ continue
194
+
195
+ # Append field value to the result list
196
+ result.append(getattr(self, f.name))
197
+
198
+ return result
199
+
82
200
  async def save(self) -> None:
83
201
  """
84
202
  Save the current data to the device.
@@ -89,11 +207,13 @@ class DataclassLoadSave:
89
207
  _LOGGER.debug('Setting data to the device: %s', str(self))
90
208
  await self._parent.command(
91
209
  self.SAVE_COMMAND,
92
- list(astuple(self))
210
+ self.serialize()
93
211
  )
94
212
 
95
213
  @classmethod
96
- async def load(cls: Type[S], parent: G90Alarm) -> S:
214
+ async def load(
215
+ cls: Type[DataclassLoadSaveT], parent: G90Alarm
216
+ ) -> DataclassLoadSaveT:
97
217
  """
98
218
  Create an instance with values loaded from the device.
99
219
 
@@ -0,0 +1,572 @@
1
+ # Copyright (c) 2026 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ """
21
+ Validation descriptors for dataclass fields.
22
+
23
+ This module provides descriptor-based validation for dataclass fields,
24
+ supporting integer range validation and string length validation.
25
+
26
+ Example usage:
27
+
28
+ from dataclasses import dataclass
29
+ from pyg90alarm.dataclass.validation import (
30
+ validated_int_field,
31
+ validated_string_field,
32
+ )
33
+
34
+ @dataclass
35
+ class Config:
36
+ # Integer field with range validation
37
+ count: int = validated_int_field(min_value=0, max_value=100)
38
+
39
+ # String field with length validation
40
+ name: str = validated_string_field(min_length=1, max_length=50)
41
+ """
42
+ from __future__ import annotations
43
+ from typing import (
44
+ Optional, Any, cast, TYPE_CHECKING, overload, TypeVar, Generic
45
+ )
46
+ import logging
47
+ from dataclasses import dataclass, field, fields, is_dataclass
48
+
49
+ from ..const import BUG_REPORT_URL
50
+ if TYPE_CHECKING:
51
+ from _typeshed import DataclassInstance
52
+
53
+ _LOGGER = logging.getLogger(__name__)
54
+
55
+
56
+ # pylint: disable=too-few-public-methods
57
+ class ValidationConstraintsAbsent:
58
+ """
59
+ Sentinel/marker type indicating that no validation constraints are defined.
60
+
61
+ This is used in type annotations and metadata to distinguish the absence
62
+ of constraints from an explicit constraints container.
63
+ """
64
+
65
+
66
+ @dataclass
67
+ class IntValidationConstraints:
68
+ """
69
+ Container for integer validation constraints.
70
+ """
71
+ min_value: Optional[int] = None
72
+ max_value: Optional[int] = None
73
+
74
+
75
+ @dataclass
76
+ class StrValidationConstraints:
77
+ """
78
+ Container for string validation constraints.
79
+ """
80
+ min_length: Optional[int] = None
81
+ max_length: Optional[int] = None
82
+
83
+
84
+ METADATA_KEY = 'validation_constraints'
85
+ T = TypeVar('T')
86
+
87
+
88
+ class ValidatorBase(Generic[T]):
89
+ """
90
+ Base dataclass descriptor for validating field values.
91
+
92
+ :param default: Default value to return upon read if not provided during
93
+ initialization.
94
+ :param trust_initial_value: If True, skips validation during dataclass
95
+ initialization assuming init value is valid.
96
+ """
97
+ def __init__(
98
+ self,
99
+ default: Optional[T] = None,
100
+ trust_initial_value: bool = False
101
+ ) -> None:
102
+ self._name: Optional[str] = None
103
+ self._default = default
104
+ self._trust_initial_value = trust_initial_value
105
+
106
+ def __validate__(self, obj: Any, value: T) -> bool:
107
+ """
108
+ Validates the value before setting.
109
+
110
+ This method should be overridden by subclasses to implement specific
111
+ validation logic.
112
+
113
+ :param obj: The dataclass instance.
114
+ :param value: The value to validate.
115
+ :raises ValueError: Exception to raise if the value is invalid.
116
+ :return: True if the value is valid, False otherwise. Only returned if
117
+ no exception is raised.
118
+ """
119
+ raise NotImplementedError(
120
+ 'Subclasses must implement __validate__() method'
121
+ )
122
+
123
+ def __set_name__(self, owner: type, name: str) -> None:
124
+ """
125
+ Stores the name of the attribute this descriptor is assigned to.
126
+
127
+ Note that the name is prepended with an underscore to avoid recursion
128
+ between __get__() and __set__(), since those aren't called for such an
129
+ attribute
130
+
131
+ :param owner: The owner class.
132
+ :param name: The name of the attribute.
133
+ """
134
+ self._name = "_" + name
135
+
136
+ @property
137
+ def __unmangled_name__(self) -> str:
138
+ """
139
+ Returns the unmangled field name.
140
+
141
+ :return: Unmangled field name.
142
+ """
143
+ return self.__field_name__[1:]
144
+
145
+ @property
146
+ def __field_name__(self) -> str:
147
+ """
148
+ Returns the field name the descriptor is assigned to.
149
+
150
+ Note that the name is mangled, see :py:meth:`__set_name__`.
151
+
152
+ :return: Field name.
153
+ """
154
+ assert self._name is not None, 'Descriptor not initialized properly'
155
+ return self._name
156
+
157
+ def __get__(
158
+ self, obj: Any, objtype: Optional[type] = None
159
+ ) -> Optional[T]:
160
+ """
161
+ Retrieves the field value, returning default if not set.
162
+
163
+ :param obj: The dataclass instance.
164
+ :param objtype: The dataclass type.
165
+ :return: The field value or default if not set.
166
+ """
167
+ # Dataclass requests the default value for the field
168
+ if obj is None:
169
+ return self._default
170
+
171
+ # Return stored value if it exists, otherwise return default if the
172
+ # value is the descriptor itself, i.e. not set
173
+ value = getattr(obj, self.__field_name__, self._default)
174
+ if value is self:
175
+ _LOGGER.debug(
176
+ "%s: Getting default value '%s'",
177
+ self.__unmangled_name__, self._default
178
+ )
179
+ value = self._default
180
+ return value
181
+
182
+ def __set__(self, obj: Any, value: T) -> None:
183
+ """
184
+ Sets the field value after validating it
185
+
186
+ The validation is skipped if `trust_initial_value` is True and this is
187
+ the first time setting the value during dataclass initialization.
188
+
189
+ :param obj: The dataclass instance.
190
+ :param value: The value to set.
191
+ """
192
+ # Default value assignment, e.g. when field not provided during
193
+ # initialization thus being assigned the descriptor instance itself
194
+ if value is self and self._default is not None:
195
+ _LOGGER.debug(
196
+ "%s: Assigning default value '%s'",
197
+ self.__unmangled_name__, self._default
198
+ )
199
+ value = self._default
200
+
201
+ # First time setting the value during dataclass initialization and its
202
+ # value should be trusted if `trust_initial_value` is True
203
+ trusted_init_value = (
204
+ not hasattr(obj, self.__field_name__)
205
+ and self._trust_initial_value
206
+ )
207
+
208
+ # Validate the value before setting
209
+ _LOGGER.debug(
210
+ "%s: Validating value '%s'", self.__unmangled_name__, value
211
+ )
212
+
213
+ try:
214
+ if not self.__validate__(obj, value):
215
+ # For unification, raise ValueError if validation fails if the
216
+ # validation method just returned False
217
+ raise ValueError(
218
+ f"Invalid value '{value}' for field"
219
+ f' {self.__unmangled_name__}'
220
+ )
221
+ except ValueError as exc:
222
+ # Validation failed for the value not being trusted during initial
223
+ # assignment, re-raise the exception
224
+ if not trusted_init_value:
225
+ _LOGGER.debug(
226
+ "%s: Validation failed for value '%s'",
227
+ self.__unmangled_name__, value
228
+ )
229
+ raise
230
+
231
+ # Log a warning about the validation failure during trusted init,
232
+ # so that constraints can be revised. The value will still be set,
233
+ # since it is trusted.
234
+ _LOGGER.warning(
235
+ "%s: Validation failed during initialization for trusted value"
236
+ " '%s' (%s). Please create bug report at %s if you see this"
237
+ " warning.",
238
+ self.__unmangled_name__, value, exc, BUG_REPORT_URL
239
+ )
240
+
241
+ # Set the validated value
242
+ _LOGGER.debug(
243
+ "%s: Setting value to '%s'", self.__unmangled_name__, value
244
+ )
245
+ setattr(obj, self.__field_name__, value)
246
+
247
+
248
+ class IntRangeValidator(ValidatorBase[int]):
249
+ """
250
+ Descriptor for validating integer field values against min/max constraints.
251
+
252
+ The field value is validated when set. A ValueError is raised if the value
253
+ is outside the specified range.
254
+
255
+ Example usage:
256
+
257
+ @dataclass
258
+ class Example:
259
+ value: int = validated_int_field(min_value=0, max_value=100)
260
+
261
+ ex = Example(value=50) # OK
262
+ ex.value = 100 # OK
263
+ ex.value = 101 # Raises ValueError
264
+
265
+ :param min_value: Minimum allowed value (inclusive), or None for no
266
+ minimum.
267
+ :param max_value: Maximum allowed value (inclusive), or None for no
268
+ maximum.
269
+ :param *: Rest of the params are passed to base class.
270
+ """
271
+ def __init__(
272
+ self, *,
273
+ min_value: Optional[int] = None,
274
+ max_value: Optional[int] = None,
275
+ **kwargs: Any
276
+ ) -> None:
277
+ super().__init__(**kwargs)
278
+ self._min_value = min_value
279
+ self._max_value = max_value
280
+
281
+ def __validate__(self, obj: Any, value: int) -> bool:
282
+ # Validate the value before setting
283
+ if value is None:
284
+ msg = (
285
+ f'{self.__unmangled_name__}: None value is not allowed'
286
+ )
287
+ _LOGGER.debug(msg)
288
+ raise ValueError(msg)
289
+
290
+ if self._min_value is not None and value < self._min_value:
291
+ msg = (
292
+ f'{self.__unmangled_name__}: Value {value} is below minimum'
293
+ f' allowed value {self._min_value}'
294
+ )
295
+ _LOGGER.debug(msg)
296
+ raise ValueError(msg)
297
+
298
+ if self._max_value is not None and value > self._max_value:
299
+ msg = (
300
+ f'{self.__unmangled_name__}: Value {value} is above maximum'
301
+ f' allowed value {self._max_value}'
302
+ )
303
+ _LOGGER.debug(msg)
304
+ raise ValueError(msg)
305
+
306
+ return True
307
+
308
+
309
+ class StringLengthValidator(ValidatorBase[str]):
310
+ """
311
+ Descriptor for validating string field values against length constraints.
312
+
313
+ The field value is validated when set. A ValueError is raised if the string
314
+ length is outside the specified range.
315
+
316
+ Example usage:
317
+
318
+ @dataclass
319
+ class Example:
320
+ name: str = validated_string_field(min_length=1, max_length=50)
321
+
322
+ ex = Example(name="hello") # OK
323
+ ex.name = "a" # OK
324
+ ex.name = "" # Raises ValueError
325
+
326
+ :param min_length: Minimum string length (inclusive), or None for no
327
+ minimum.
328
+ :param max_length: Maximum string length (inclusive), or None for no
329
+ maximum.
330
+ :param *: Rest of the params are passed to base class.
331
+ """
332
+ def __init__(
333
+ self,
334
+ *,
335
+ min_length: Optional[int] = None,
336
+ max_length: Optional[int] = None,
337
+ **kwargs: Any
338
+ ) -> None:
339
+ super().__init__(**kwargs)
340
+ self._min_length = min_length
341
+ self._max_length = max_length
342
+
343
+ def __validate__(self, obj: Any, value: str) -> bool:
344
+ if value is None:
345
+ msg = (
346
+ f'{self.__unmangled_name__}: None value is not allowed'
347
+ )
348
+ _LOGGER.debug(msg)
349
+ raise ValueError(msg)
350
+
351
+ length = len(value)
352
+
353
+ # Validate the length before setting
354
+ if self._min_length is not None and length < self._min_length:
355
+ msg = (
356
+ f'{self.__unmangled_name__}: String length {length} is below'
357
+ f' minimum allowed length {self._min_length}'
358
+ )
359
+ _LOGGER.debug(msg)
360
+ raise ValueError(msg)
361
+
362
+ if self._max_length is not None and length > self._max_length:
363
+ msg = (
364
+ f'{self.__unmangled_name__}: String length {length} is above'
365
+ f' maximum allowed length {self._max_length}'
366
+ )
367
+ _LOGGER.debug(msg)
368
+ raise ValueError(msg)
369
+
370
+ return True
371
+
372
+
373
+ def validated_int_field(
374
+ *,
375
+ min_value: Optional[int] = None,
376
+ max_value: Optional[int] = None,
377
+ default: Optional[int] = None,
378
+ trust_initial_value: bool = False,
379
+ **kwargs: Any
380
+ ) -> int:
381
+ """
382
+ Create a dataclass field with integer range validation.
383
+
384
+ The field value will be validated when set, raising ValueError if outside
385
+ the specified range. Validation constraints are stored in field metadata
386
+ for later retrieval.
387
+
388
+ :param min_value: Minimum allowed value (inclusive), or None for no
389
+ minimum.
390
+ :param max_value: Maximum allowed value (inclusive), or None for no
391
+ maximum.
392
+ :param default: Default value for the field, or None for no default.
393
+ :param trust_initial_value: If True, skips validation during dataclass
394
+ initialization assuming init value is valid.
395
+ :param kwargs: Additional keyword arguments to pass to dataclasses.field().
396
+ :return: A dataclass field with IntRangeValidator descriptor and metadata.
397
+
398
+ Example:
399
+
400
+ @dataclass
401
+ class Config:
402
+ count: int = validated_int_field(
403
+ min_value=0, max_value=100, default=50
404
+ )
405
+ """
406
+ # Store validation constraints in metadata
407
+ if 'metadata' not in kwargs:
408
+ kwargs['metadata'] = {}
409
+ else:
410
+ # Avoid modifying caller's dict
411
+ kwargs['metadata'] = dict(kwargs['metadata'])
412
+
413
+ metadata = IntValidationConstraints(min_value, max_value)
414
+ kwargs['metadata'][METADATA_KEY] = metadata
415
+
416
+ # Create the field with the descriptor as default, passing along default
417
+ # value
418
+ # pylint: disable=invalid-field-call
419
+ return cast(int, field(
420
+ **kwargs,
421
+ default=IntRangeValidator(
422
+ min_value=min_value, max_value=max_value, default=default,
423
+ trust_initial_value=trust_initial_value
424
+ )
425
+ ))
426
+
427
+
428
+ def validated_string_field(
429
+ *,
430
+ min_length: Optional[int] = None,
431
+ max_length: Optional[int] = None,
432
+ default: Optional[str] = None,
433
+ trust_initial_value: bool = False,
434
+ **kwargs: Any
435
+ ) -> str:
436
+ """
437
+ Create a dataclass field with string length validation.
438
+
439
+ The field value will be validated when set, raising ValueError if the
440
+ string length is outside the specified range. Validation constraints are
441
+ stored in field metadata for later retrieval.
442
+
443
+ :param min_length: Minimum string length (inclusive), or None for no
444
+ minimum.
445
+ :param max_length: Maximum string length (inclusive), or None for no
446
+ maximum.
447
+ :param default: Default value for the field, or None for no default.
448
+ :param trust_initial_value: If True, skips validation during dataclass
449
+ initialization assuming init value is valid.
450
+ :param kwargs: Additional keyword arguments to pass to dataclasses.field().
451
+ :return: A dataclass field with StringLengthValidator descriptor and
452
+ metadata.
453
+
454
+ Example:
455
+
456
+ @dataclass
457
+ class Config:
458
+ name: str = validated_string_field(
459
+ min_length=1, max_length=50, default="default"
460
+ )
461
+ """
462
+ # Store validation constraints in metadata
463
+ if 'metadata' not in kwargs:
464
+ kwargs['metadata'] = {}
465
+ else:
466
+ # Avoid modifying caller's dict
467
+ kwargs['metadata'] = dict(kwargs['metadata'])
468
+
469
+ metadata = StrValidationConstraints(min_length, max_length)
470
+ kwargs['metadata'][METADATA_KEY] = metadata
471
+
472
+ # Create the field with the descriptor as default, passing along default
473
+ # value
474
+ # pylint: disable=invalid-field-call
475
+ return cast(str, field(
476
+ **kwargs,
477
+ default=StringLengthValidator(
478
+ min_length=min_length, max_length=max_length, default=default,
479
+ trust_initial_value=trust_initial_value
480
+ )
481
+ ))
482
+
483
+
484
+ # `get_field_validation_constraints` overload for `int` type
485
+ @overload
486
+ def get_field_validation_constraints(
487
+ dataclass_type: DataclassInstance | type[DataclassInstance],
488
+ field_name: str, expected_type: type[int]
489
+ ) -> IntValidationConstraints:
490
+ ...
491
+
492
+
493
+ # `get_field_validation_constraints` overload for `str` type
494
+ @overload
495
+ def get_field_validation_constraints(
496
+ dataclass_type: DataclassInstance | type[DataclassInstance],
497
+ field_name: str, expected_type: type[str]
498
+ ) -> StrValidationConstraints:
499
+ ...
500
+
501
+
502
+ # `get_field_validation_constraints` overload when no expected type is given
503
+ @overload
504
+ def get_field_validation_constraints(
505
+ dataclass_type: DataclassInstance | type[DataclassInstance],
506
+ field_name: str, expected_type: None = None
507
+ ) -> ValidationConstraintsAbsent:
508
+ ...
509
+
510
+
511
+ # `get_field_validation_constraints` overload when non-dataclass is given
512
+ @overload
513
+ def get_field_validation_constraints(
514
+ dataclass_type: Any,
515
+ field_name: str, expected_type: None = None
516
+ ) -> ValidationConstraintsAbsent:
517
+ ...
518
+
519
+
520
+ def get_field_validation_constraints(
521
+ dataclass_type: DataclassInstance | type[DataclassInstance],
522
+ field_name: str, expected_type: Optional[type] = None
523
+ ) -> Any:
524
+ """
525
+ Retrieve validation constraints for a specific dataclass field.
526
+
527
+ :param dataclass_type: The dataclass type or instance to inspect.
528
+ :param field_name: The name of the field to get constraints for.
529
+ :param expected_type: Expected type of the field to
530
+ determine which constraints to retrieve.
531
+ :return: Validation constraints container, or ValidationConstraintsAbsent
532
+ if none found. The latter is to avoid returning None and free callers from
533
+ having to check for it.
534
+
535
+ Example:
536
+
537
+ constraints = get_field_validation_constraints(Config, 'count', int)
538
+ # Returns: object with `constraints.min_value`, `constraints.max_value`
539
+ """
540
+ if not is_dataclass(dataclass_type):
541
+ # Not a dataclass, return absent indicator
542
+ return ValidationConstraintsAbsent()
543
+
544
+ fields_list = fields(dataclass_type)
545
+ for f in fields_list:
546
+ # Find the field by name
547
+ if f.name != field_name:
548
+ continue
549
+
550
+ # Retrieve validation constraints from metadata per expected type
551
+ for typ, klass in (
552
+ # Constraints for `int` field
553
+ (int, IntValidationConstraints),
554
+ # Constraints for `str` field
555
+ (str, StrValidationConstraints),
556
+ ):
557
+ if expected_type is not typ:
558
+ continue
559
+
560
+ constraints = (
561
+ getattr(f, 'metadata', {}).get(METADATA_KEY, None)
562
+ )
563
+
564
+ # Return stored constraints if available
565
+ if isinstance(constraints, klass):
566
+ return constraints
567
+
568
+ # Otherwise return empty constraints
569
+ return klass()
570
+
571
+ # No validation constraints found, return absent indicator
572
+ return ValidationConstraintsAbsent()
@@ -68,6 +68,7 @@ class G90BaseList(Generic[T], ABC):
68
68
 
69
69
  :return: Async generator of entities
70
70
  """
71
+ # Placeholder to satisfy the abstractmethod
71
72
  yield cast(T, None) # pragma: no cover
72
73
 
73
74
  @property
@@ -118,7 +119,11 @@ class G90BaseList(Generic[T], ABC):
118
119
  )
119
120
 
120
121
  existing_entity.update(entity)
121
- non_existing_entities.remove(existing_entity)
122
+ # The entity might have already been removed if there
123
+ # are duplicate entities from the `_fetch` method,
124
+ # protect against that
125
+ if existing_entity in non_existing_entities:
126
+ non_existing_entities.remove(existing_entity)
122
127
 
123
128
  # Invoke the list change callback for the existing
124
129
  # entity to notify about the update
pyg90alarm/exceptions.py CHANGED
@@ -32,7 +32,7 @@ class G90Error(Exception):
32
32
  """
33
33
 
34
34
 
35
- class G90TimeoutError(asyncio.TimeoutError): # pylint:disable=R0903
35
+ class G90TimeoutError(asyncio.TimeoutError, G90Error):
36
36
  """
37
37
  Raised when particular package class to report an operation (typically
38
38
  device command) has timed out.
@@ -24,7 +24,8 @@ from __future__ import annotations
24
24
  from typing import Dict, Any
25
25
  from dataclasses import dataclass
26
26
  from ..const import G90Commands
27
- from .dataclass_load_save import DataclassLoadSave
27
+ from ..dataclass.load_save import DataclassLoadSave
28
+ from ..dataclass.validation import validated_string_field
28
29
 
29
30
 
30
31
  @dataclass
@@ -36,28 +37,54 @@ class G90AlarmPhones(DataclassLoadSave):
36
37
  LOAD_COMMAND = G90Commands.GETALMPHONE
37
38
  SAVE_COMMAND = G90Commands.SETALMPHONE
38
39
 
40
+ # The field constraints below have been determined experimentally by
41
+ # entering various values into panel configuration manually. All values
42
+ # received from the panel remotely are trusted (i.e. bypass validation)
43
+
39
44
  # Password to operate the panel via SMS or incoming call.
40
- panel_password: str
45
+ panel_password: str = validated_string_field(
46
+ min_length=4, max_length=4, trust_initial_value=True
47
+ )
41
48
  # Phone number of the alarm panel's SIM card.
42
- panel_phone_number: str
49
+ # Might be empty, the panel will then presumably refuse handling SMS/calls
50
+ # with remote commands.
51
+ panel_phone_number: str = validated_string_field(
52
+ max_length=14, trust_initial_value=True
53
+ )
43
54
  # Alarm phone number to be called on alarm.
44
- # Should be in country code + number format.
45
- phone_number_1: str
55
+ # Should be in country code + number format, or empty to disable.
56
+ phone_number_1: str = validated_string_field(
57
+ max_length=14, trust_initial_value=True
58
+ )
46
59
  # Same, but for second alarm phone number.
47
- phone_number_2: str
60
+ phone_number_2: str = validated_string_field(
61
+ max_length=14, trust_initial_value=True
62
+ )
48
63
  # Same, but for third alarm phone number.
49
- phone_number_3: str
64
+ phone_number_3: str = validated_string_field(
65
+ max_length=14, trust_initial_value=True
66
+ )
50
67
  # Same, but for fourth alarm phone number.
51
- phone_number_4: str
68
+ phone_number_4: str = validated_string_field(
69
+ max_length=14, trust_initial_value=True
70
+ )
52
71
  # Same, but for fifth alarm phone number.
53
- phone_number_5: str
72
+ phone_number_5: str = validated_string_field(
73
+ max_length=14, trust_initial_value=True
74
+ )
54
75
  # Same, but for sixth alarm phone number.
55
- phone_number_6: str
76
+ phone_number_6: str = validated_string_field(
77
+ max_length=14, trust_initial_value=True
78
+ )
56
79
  # Phone number to send SMS notifications on alarm.
57
80
  # Should be in country code + number format.
58
- sms_push_number_1: str
81
+ sms_push_number_1: str = validated_string_field(
82
+ max_length=14, trust_initial_value=True
83
+ )
59
84
  # Same, but for second SMS notification phone number.
60
- sms_push_number_2: str
85
+ sms_push_number_2: str = validated_string_field(
86
+ max_length=14, trust_initial_value=True
87
+ )
61
88
 
62
89
  def _asdict(self) -> Dict[str, Any]:
63
90
  """
@@ -21,10 +21,13 @@
21
21
  Protocol entity for G90 alarm panel config.
22
22
  """
23
23
  from __future__ import annotations
24
- from typing import Dict, Any
24
+ from typing import Dict, Any, Optional
25
25
  from dataclasses import dataclass
26
26
  from enum import IntEnum
27
- from .dataclass_load_save import DataclassLoadSave
27
+ from ..dataclass.load_save import (
28
+ DataclassLoadSave, field_readonly_if_not_provided,
29
+ )
30
+ from ..dataclass.validation import validated_int_field
28
31
  from ..const import G90Commands
29
32
 
30
33
 
@@ -70,28 +73,60 @@ class G90HostConfig(DataclassLoadSave):
70
73
  LOAD_COMMAND = G90Commands.GETHOSTCONFIG
71
74
  SAVE_COMMAND = G90Commands.SETHOSTCONFIG
72
75
 
76
+ # The field constraints below have been determined experimentally by
77
+ # entering various values into panel configuration manually. All values
78
+ # received from the panel remotely are trusted (i.e. bypass validation)
79
+
73
80
  # Duration of the alarm siren when triggered, in seconds
74
- alarm_siren_duration: int
81
+ alarm_siren_duration: int = validated_int_field(
82
+ min_value=0, max_value=999, trust_initial_value=True
83
+ )
75
84
  # Delay before arming the panel, in seconds
76
- arm_delay: int
85
+ arm_delay: int = validated_int_field(
86
+ min_value=0, max_value=255, trust_initial_value=True
87
+ )
77
88
  # Delay before the alarm is triggered, in seconds
78
- alarm_delay: int
89
+ alarm_delay: int = validated_int_field(
90
+ min_value=0, max_value=255, trust_initial_value=True
91
+ )
79
92
  # Duration of the backlight, in seconds
80
- backlight_duration: int
93
+ backlight_duration: int = validated_int_field(
94
+ min_value=0, max_value=255, trust_initial_value=True
95
+ )
81
96
  # Alarm volume level, applies to panel's built-in speaker
82
- _alarm_volume_level: int
97
+ _alarm_volume_level: int = validated_int_field(
98
+ min_value=min(G90VolumeLevel), max_value=max(G90VolumeLevel),
99
+ trust_initial_value=True
100
+ )
83
101
  # Speech volume level
84
- _speech_volume_level: int
102
+ _speech_volume_level: int = validated_int_field(
103
+ min_value=min(G90VolumeLevel), max_value=max(G90VolumeLevel),
104
+ trust_initial_value=True
105
+ )
85
106
  # Duration of the ring for the incoming call, in seconds
86
- ring_duration: int
107
+ ring_duration: int = validated_int_field(
108
+ min_value=0, max_value=255, trust_initial_value=True
109
+ )
87
110
  # Speech language
88
- _speech_language: int
111
+ _speech_language: int = validated_int_field(
112
+ min_value=min(G90SpeechLanguage), max_value=max(G90SpeechLanguage),
113
+ trust_initial_value=True
114
+ )
89
115
  # Key tone volume level
90
- _key_tone_volume_level: int
116
+ _key_tone_volume_level: int = validated_int_field(
117
+ min_value=min(G90VolumeLevel), max_value=max(G90VolumeLevel),
118
+ trust_initial_value=True
119
+ )
91
120
  # Timezone offset, in minutes
92
- timezone_offset_m: int
93
- # Ring volume level for incoming calls
94
- _ring_volume_level: int
121
+ timezone_offset_m: int = validated_int_field(
122
+ min_value=-720, max_value=720, trust_initial_value=True
123
+ )
124
+ # Ring volume level for incoming calls, could only be modified if the
125
+ # device has sent a value for it when loading the data (i.e. has a cellular
126
+ # module) otherwise it is read-only and None
127
+ _ring_volume_level: Optional[int] = field_readonly_if_not_provided(
128
+ default=None
129
+ )
95
130
 
96
131
  @property
97
132
  def speech_language(self) -> G90SpeechLanguage:
@@ -138,10 +173,16 @@ class G90HostConfig(DataclassLoadSave):
138
173
  self._key_tone_volume_level = value.value
139
174
 
140
175
  @property
141
- def ring_volume_level(self) -> G90VolumeLevel:
176
+ def ring_volume_level(self) -> Optional[G90VolumeLevel]:
142
177
  """
143
178
  Returns the ring volume level as an enum.
179
+
180
+ :return: Ring volume level, or `None` if the device does not have
181
+ cellular module.
144
182
  """
183
+ if self._ring_volume_level is None:
184
+ return None
185
+
145
186
  return G90VolumeLevel(self._ring_volume_level)
146
187
 
147
188
  @ring_volume_level.setter
@@ -163,5 +204,6 @@ class G90HostConfig(DataclassLoadSave):
163
204
  'speech_language': self.speech_language.name,
164
205
  'key_tone_volume_level': self.key_tone_volume_level.name,
165
206
  'timezone_offset_m': self.timezone_offset_m,
166
- 'ring_volume_level': self.ring_volume_level.name,
207
+ # The field is optional
208
+ 'ring_volume_level': getattr(self.ring_volume_level, 'name', None)
167
209
  }
@@ -22,10 +22,11 @@ Interprets network configuration data fields of GETAPINFO/SETAPINFO commands.
22
22
  """
23
23
  from __future__ import annotations
24
24
  from enum import IntEnum
25
- from dataclasses import dataclass
26
- from typing import Any, Dict
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Dict, Optional
27
27
  from ..const import G90Commands
28
- from .dataclass_load_save import DataclassLoadSave
28
+ from ..dataclass.load_save import DataclassLoadSave, Metadata
29
+ from ..dataclass.validation import validated_int_field, validated_string_field
29
30
 
30
31
 
31
32
  class G90APNAuth(IntEnum):
@@ -47,27 +48,52 @@ class G90NetConfig(DataclassLoadSave):
47
48
  LOAD_COMMAND = G90Commands.GETAPINFO
48
49
  SAVE_COMMAND = G90Commands.SETAPINFO
49
50
 
51
+ # The field constraints below have been determined experimentally by
52
+ # entering various values into panel configuration manually. All values
53
+ # received from the panel remotely are trusted (i.e. bypass validation)
54
+
50
55
  # Whether the access point is enabled, so that the device can be accessed
51
56
  # via WiFi
52
- _ap_enabled: int
57
+ _ap_enabled: int = validated_int_field(
58
+ min_value=0, max_value=1, trust_initial_value=True
59
+ )
53
60
  # Access point password
54
- ap_password: str
61
+ ap_password: str = validated_string_field(
62
+ min_length=9, max_length=64, trust_initial_value=True
63
+ )
55
64
  # Whether WiFi is enabled, so that the device can connect to WiFi network
56
- _wifi_enabled: int
65
+ _wifi_enabled: int = validated_int_field(
66
+ min_value=0, max_value=1, trust_initial_value=True
67
+ )
57
68
  # Whether GPRS is enabled, so that the device can connect via cellular
58
69
  # network
59
- _gprs_enabled: int
70
+ _gprs_enabled: int = validated_int_field(
71
+ min_value=0, max_value=1, trust_initial_value=True
72
+ )
60
73
  # Access Point Name (APN) for GPRS connection, as provided by the cellular
61
74
  # operator
62
- apn_name: str
75
+ apn_name: str = validated_string_field(
76
+ min_length=1, max_length=100, trust_initial_value=True
77
+ )
63
78
  # User name for APN authentication, as provided by the cellular operator
64
- apn_user: str
79
+ apn_user: str = validated_string_field(
80
+ min_length=0, max_length=64, trust_initial_value=True
81
+ )
65
82
  # Password for APN authentication, as provided by the cellular operator
66
- apn_password: str
83
+ apn_password: str = validated_string_field(
84
+ min_length=0, max_length=64, trust_initial_value=True
85
+ )
67
86
  # APN authentication method, as provided by the cellular operator
68
- _apn_auth: int
69
- # GSM operator code
70
- gsm_operator: str
87
+ _apn_auth: int = validated_int_field(
88
+ min_value=min(G90APNAuth), max_value=max(G90APNAuth),
89
+ trust_initial_value=True
90
+ )
91
+ # GSM operator code, optional for devices lacking cellular module.
92
+ # The field is always skipped when saving to device.
93
+ gsm_operator: Optional[str] = field(
94
+ metadata={Metadata.NO_SERIALIZE: True},
95
+ default=None
96
+ )
71
97
 
72
98
  @property
73
99
  def ap_enabled(self) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyg90alarm
3
- Version: 2.4.2
3
+ Version: 2.5.1
4
4
  Summary: G90 Alarm system protocol
5
5
  Home-page: https://github.com/hostcc/pyg90alarm
6
6
  Author: Ilia Sotnikov
@@ -1,38 +1,40 @@
1
- pyg90alarm/__init__.py,sha256=hZXvjpbWNO0ae88_B2qR4XDG6JXjPx5EZK8T2qxMcMM,3473
1
+ pyg90alarm/__init__.py,sha256=ehUcPV8O2jBszU3hPAfI91nXjj2UXIfYjoREvL4QE2c,3719
2
2
  pyg90alarm/alarm.py,sha256=BDBVQ8qRa2Bx2-786uytShTw8znlFEWakxweCGvgxME,50130
3
3
  pyg90alarm/callback.py,sha256=9PVtjRs2MLn80AgiM-UJNL8ZJF4_PxcopJIpxMmB3vc,4707
4
- pyg90alarm/const.py,sha256=XBmtojOV0OrjqwL7x_wixqnt4Vcs9xOGlz-wHY4uO_Q,7948
4
+ pyg90alarm/const.py,sha256=cpaMlb8rqE1EfvmUEh3Yd_29dNO0usIo_Y-esPBh8B0,8012
5
5
  pyg90alarm/event_mapping.py,sha256=hSmRWkkuA5hlauGvYakdOrw8QFt0TMNfUuDQ4J3vHpQ,3438
6
- pyg90alarm/exceptions.py,sha256=9LleQC2SkJXjv80FlWMeaHs9ZjXxCvZx9UtYdrvejyY,1951
6
+ pyg90alarm/exceptions.py,sha256=9rSd5zIKALHtRHBBLD-R5zxW-5OBkHK04uVupCnAK8s,1937
7
7
  pyg90alarm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  pyg90alarm/cloud/__init__.py,sha256=u7ZzrIwCsdgmBmgtpCe6c_Kipdo8nG8PEOEcaAHXCxI,1407
9
9
  pyg90alarm/cloud/const.py,sha256=FYUxkj0DSI7nnXZHsJMfv8RNQiXKV3TYKGHSdfHdMjg,2113
10
10
  pyg90alarm/cloud/messages.py,sha256=L3cpP3IbDRdw3W6FeQxTPXoDlpUv7Fm4t-RkH9Uj4dg,18032
11
11
  pyg90alarm/cloud/notifications.py,sha256=0RxCBVcvDuwE0I1m3SLDXDQqCJimDcN4f45gr-Hvt1A,15669
12
12
  pyg90alarm/cloud/protocol.py,sha256=82l2IXSM12tv_iWkTrAQZ-aw5UR4tmWFQJKVcgBfIww,16159
13
+ pyg90alarm/dataclass/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ pyg90alarm/dataclass/load_save.py,sha256=I2WDeW4GingN8AEiwZm9Epf1gVL_Ynkise2h781Dn3I,8604
15
+ pyg90alarm/dataclass/validation.py,sha256=yd3Q86S0L5NekzoiVFW5SwpefbIhWpwvsS-S15X4Sf8,19107
13
16
  pyg90alarm/definitions/__init__.py,sha256=s0NZnkW_gMH718DJbgez28z9WA231CyszUf1O_ojUiI,68
14
17
  pyg90alarm/definitions/base.py,sha256=Q2DQXfFks9u-ke65_5xVTqCUXJmJGxBxFTeWnite1bw,6757
15
18
  pyg90alarm/definitions/devices.py,sha256=K0DQnyE-1hlhIGwRfZojKJYMSmlJzmyWcZ_98urixsc,12042
16
19
  pyg90alarm/definitions/sensors.py,sha256=bDecBGyUo7wFVNuD5Fu1JNZQHcMDv-234BuNKioaQQs,27426
17
20
  pyg90alarm/entities/__init__.py,sha256=hHb6AOiC4Tz--rOWiiICMdLaZDs1Tf_xpWk_HeS_gO4,66
18
21
  pyg90alarm/entities/base_entity.py,sha256=hNhhuuwNuir54uVMZzPbjE0N6WL8wKvoW_KZa4R8L8U,2680
19
- pyg90alarm/entities/base_list.py,sha256=vNP0T8qg7lgrES0IBs4zOggQM6Uxyfarfds46bbAnH0,9482
22
+ pyg90alarm/entities/base_list.py,sha256=oyhCszJZuVUeIoDCmMi_1XkQ87HK297_bnbRJBpYLv0,9807
20
23
  pyg90alarm/entities/device.py,sha256=eWE_N83hiDs5I-TT5F_W0Vb8sVugLldrDc9Lj9sgVLo,3700
21
24
  pyg90alarm/entities/device_list.py,sha256=PKnHEazeT3iUEaz70bW1OaWh0wq-7WOY-7dpV4FVCTc,5984
22
25
  pyg90alarm/entities/sensor.py,sha256=pq4tARSKQC_AJlT_ZaBcqTDtaXKuyo5nz6BqmHohSQk,27649
23
26
  pyg90alarm/entities/sensor_list.py,sha256=0S88bhTn91O45WgbIIMQ0iXaNjlUWmMBOOFwj2Hg73U,6993
24
27
  pyg90alarm/local/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
- pyg90alarm/local/alarm_phones.py,sha256=OKH7t8jBziHOSJth1bnYKYVKFiX1o83JzA-6ijCYBDc,3149
28
+ pyg90alarm/local/alarm_phones.py,sha256=bGg4aEJwtl7oMlLdOGpW2Vv4q4zS9wzkpW4q3Qy2JCU,4375
26
29
  pyg90alarm/local/alert_config.py,sha256=n2dycEf6TH0MKefQXMcRsCFJyYvUMV55LRsy4FARtDI,6027
27
30
  pyg90alarm/local/base_cmd.py,sha256=f0PozHJjErIg08vn0sAacHbigJSFor8q5jdxfXiM27c,10491
28
31
  pyg90alarm/local/config.py,sha256=QYetAc6QLrAN8T-37D4Esifvao52w_uJ01nHciLbGME,1390
29
- pyg90alarm/local/dataclass_load_save.py,sha256=wmHT5834_rOlgPMe0ryZW0SbdFNTLC6-4NjJlSt6mhY,4738
30
32
  pyg90alarm/local/discovery.py,sha256=8YVIXuNe57lhas0VSRf7QXZH83pEDGNj9oehNY4Kh2U,3576
31
33
  pyg90alarm/local/history.py,sha256=sL6_Z1BNYkuUwAZUi78d4p5hhcCfsXKw29i4Qu1O60M,10811
32
- pyg90alarm/local/host_config.py,sha256=ycGx8WdSQ_azuBWpK366gvldZcxfVGnYBa_frbI9-TM,5502
34
+ pyg90alarm/local/host_config.py,sha256=KMtrkM7IPm2b38f_kCIVGxYvPOd9ZDoA3nIoT-2SBKg,7376
33
35
  pyg90alarm/local/host_info.py,sha256=4lFIaFEpYd3EvgNrDJmKijTrzX9i29nFISLLlXGnkmE,2759
34
36
  pyg90alarm/local/host_status.py,sha256=WHGtw-A0wHILqVWP4DnZhXj8DPRGyS26AV0bL1isTz4,1863
35
- pyg90alarm/local/net_config.py,sha256=wjJMzL6tcHAfYF-OtregRpCoNEviOlkHHr5SpDaTmNs,4157
37
+ pyg90alarm/local/net_config.py,sha256=x-hrisOktdy1ChacCoVBjaOq75KiDJ36nT1JsPGS42g,5453
36
38
  pyg90alarm/local/notifications.py,sha256=Vs6NQJciYqDALV-WwzH6wIcTGdX_UD4XBuHWjSOpCDY,4591
37
39
  pyg90alarm/local/paginated_cmd.py,sha256=5pPVP8f4ydjgu8Yq6MwqINJAUt52fFlD17wO4AI88Pc,4467
38
40
  pyg90alarm/local/paginated_result.py,sha256=p_e8QAVznp1Q5Xi9ifjb9Bx-S3ZiAkVlPKrY6r0bYLs,5483
@@ -41,8 +43,8 @@ pyg90alarm/local/user_data_crc.py,sha256=JQBOPY3RlOgVtvR55R-rM8OuKjYW-BPXQ0W4pi6
41
43
  pyg90alarm/notifications/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
44
  pyg90alarm/notifications/base.py,sha256=d3N_zNPa_jcTX4QpA78jdgMHDhmrgwqyM3HdvuO14Jk,16682
43
45
  pyg90alarm/notifications/protocol.py,sha256=TlZQ3P8-N-E2X5bzkGefz432x4lBYyIBF9VriwYn9ds,4790
44
- pyg90alarm-2.4.2.dist-info/licenses/LICENSE,sha256=f884inRbeNv-O-hbwz62Ro_1J8xiHRTnJ2cCx6A0WvU,1070
45
- pyg90alarm-2.4.2.dist-info/METADATA,sha256=vS1FIyQ1EwK8fbEBs2EsamOz93YLtwJUwmxoslRD5Ak,12568
46
- pyg90alarm-2.4.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
47
- pyg90alarm-2.4.2.dist-info/top_level.txt,sha256=czHiGxYMyTk5QEDTDb0EpPiKqUMRa8zI4zx58Ii409M,11
48
- pyg90alarm-2.4.2.dist-info/RECORD,,
46
+ pyg90alarm-2.5.1.dist-info/licenses/LICENSE,sha256=f884inRbeNv-O-hbwz62Ro_1J8xiHRTnJ2cCx6A0WvU,1070
47
+ pyg90alarm-2.5.1.dist-info/METADATA,sha256=C-0PG6RSQTPeWYdMNOE-fzZ65N8B0qoemx39bYOIDw8,12568
48
+ pyg90alarm-2.5.1.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
49
+ pyg90alarm-2.5.1.dist-info/top_level.txt,sha256=czHiGxYMyTk5QEDTDb0EpPiKqUMRa8zI4zx58Ii409M,11
50
+ pyg90alarm-2.5.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5