pyvesync 3.0.0__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.
Files changed (50) hide show
  1. pyvesync/__init__.py +5 -0
  2. pyvesync/base_devices/__init__.py +35 -0
  3. pyvesync/base_devices/bulb_base.py +330 -0
  4. pyvesync/base_devices/fan_base.py +392 -0
  5. pyvesync/base_devices/fryer_base.py +68 -0
  6. pyvesync/base_devices/humidifier_base.py +462 -0
  7. pyvesync/base_devices/outlet_base.py +327 -0
  8. pyvesync/base_devices/purifier_base.py +525 -0
  9. pyvesync/base_devices/switch_base.py +271 -0
  10. pyvesync/base_devices/thermostat_base.py +283 -0
  11. pyvesync/base_devices/vesyncbasedevice.py +572 -0
  12. pyvesync/const.py +825 -0
  13. pyvesync/device_container.py +373 -0
  14. pyvesync/device_map.py +1172 -0
  15. pyvesync/devices/__init__.py +1 -0
  16. pyvesync/devices/vesyncbulb.py +1126 -0
  17. pyvesync/devices/vesyncfan.py +233 -0
  18. pyvesync/devices/vesynchumidifier.py +808 -0
  19. pyvesync/devices/vesynckitchen.py +651 -0
  20. pyvesync/devices/vesyncoutlet.py +954 -0
  21. pyvesync/devices/vesyncpurifier.py +990 -0
  22. pyvesync/devices/vesyncswitch.py +415 -0
  23. pyvesync/devices/vesyncthermostat.py +171 -0
  24. pyvesync/models/__init__.py +11 -0
  25. pyvesync/models/base_models.py +129 -0
  26. pyvesync/models/bulb_models.py +197 -0
  27. pyvesync/models/bypass_models.py +289 -0
  28. pyvesync/models/fan_models.py +47 -0
  29. pyvesync/models/fryer_models.py +34 -0
  30. pyvesync/models/home_models.py +169 -0
  31. pyvesync/models/humidifier_models.py +202 -0
  32. pyvesync/models/outlet_models.py +220 -0
  33. pyvesync/models/purifier_models.py +261 -0
  34. pyvesync/models/switch_models.py +101 -0
  35. pyvesync/models/thermostat_models.py +134 -0
  36. pyvesync/models/vesync_models.py +439 -0
  37. pyvesync/utils/__init__.py +1 -0
  38. pyvesync/utils/colors.py +250 -0
  39. pyvesync/utils/device_mixins.py +362 -0
  40. pyvesync/utils/enum_utils.py +26 -0
  41. pyvesync/utils/errors.py +867 -0
  42. pyvesync/utils/helpers.py +700 -0
  43. pyvesync/utils/logs.py +525 -0
  44. pyvesync/vesync.py +638 -0
  45. pyvesync/vesynchome.py +152 -0
  46. pyvesync-3.0.0.dist-info/METADATA +567 -0
  47. pyvesync-3.0.0.dist-info/RECORD +50 -0
  48. pyvesync-3.0.0.dist-info/WHEEL +5 -0
  49. pyvesync-3.0.0.dist-info/licenses/LICENSE +21 -0
  50. pyvesync-3.0.0.dist-info/top_level.txt +1 -0
pyvesync/utils/logs.py ADDED
@@ -0,0 +1,525 @@
1
+ """library_logger.py.
2
+
3
+ This module provides a custom logger for libraries. It automatically
4
+ captures the caller's module and class name in the log records, and it
5
+ provides helper methods for logging API calls and other specially formatted
6
+ messages.
7
+
8
+ Usage:
9
+ from library_logger import LibraryLogger
10
+
11
+ # Use `LibraryLogger.log_api_call` to log API calls.
12
+ LibraryLogger.log_api_call(logger, response)
13
+
14
+ # Use `LibraryLogger.log_api_exception` to log API exceptions.
15
+ LibraryLogger.log_api_exception(logger, request_dict, exception)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import re
23
+ from collections.abc import Mapping
24
+ from dataclasses import fields, is_dataclass
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING
27
+
28
+ import orjson
29
+ from mashumaro.exceptions import InvalidFieldValue, MissingField, UnserializableField
30
+ from multidict import CIMultiDictProxy
31
+
32
+ if TYPE_CHECKING:
33
+ from aiohttp import ClientResponse
34
+ from aiohttp.client_exceptions import ClientResponseError
35
+
36
+ from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
37
+
38
+
39
+ class LibraryLogger:
40
+ """Library Logging Interface.
41
+
42
+ LibraryLogger provides a logging interface that automatically adds context
43
+ (module and class name) and offers helper methods for structured logging,
44
+ such as API call logs.
45
+
46
+ Attributes:
47
+ debug (bool): Class attribute to enable or disable debug logging,
48
+ prints API requests & responses that return an error.
49
+ shouldredact (bool): Class attribute whether to redact
50
+ sensitive information from logs.
51
+ verbose (bool): Class attribute to print all request & response content.
52
+
53
+ Examples:
54
+ Logging an API call:
55
+ >>> LibraryLogger.log_api_call(logger, response)
56
+ 2025-02-01 12:34:56 - DEBUG - my_library - ========API CALL========
57
+ API CALL to endpoint: /api/v1/resource
58
+ Method: POST
59
+ Request Headers:
60
+ {
61
+ REQUEST HEADERS
62
+ }
63
+ Request Body:
64
+ {
65
+ "key": "value"
66
+ }
67
+ Response Body:
68
+ {
69
+ RESPONSE BODY
70
+ }
71
+
72
+ Logging an API exception:
73
+ >>> LibraryLogger.log_api_exception(logger, request_dict, exception)
74
+ 2025-02-01 12:34:56 - DEBUG - pyvesync -
75
+ Error in API CALL to endpoint: /api/v1/resource
76
+ Response Status: 500
77
+ Exception: <Exception>
78
+ Method: POST
79
+ Request Headers:
80
+ {
81
+ REQUEST HEADERS
82
+ }
83
+ Request Body:
84
+ {
85
+ "key": "value"
86
+ }
87
+ """
88
+
89
+ debug = False
90
+ """Class attribute to enable or disable debug logging -
91
+ prints request and response content for errors only."""
92
+ shouldredact = True
93
+ """Class attribute to determine if sensitive information should be redacted."""
94
+ verbose = False
95
+ """Class attribute to print all request & response content."""
96
+
97
+ @classmethod
98
+ def redactor(cls, stringvalue: str) -> str:
99
+ """Redact sensitive strings from debug output.
100
+
101
+ This method searches for specific sensitive keys in the input string and replaces
102
+ their values with '##_REDACTED_##'. The keys that are redacted include:
103
+
104
+ - token
105
+ - authorizeCode
106
+ - password
107
+ - email
108
+ - tk
109
+ - accountId
110
+ - authKey
111
+ - uuid
112
+ - cid
113
+
114
+ Args:
115
+ stringvalue (str): The input string potentially containing
116
+ sensitive information.
117
+
118
+ Returns:
119
+ str: The redacted string with sensitive information replaced
120
+ by '##_REDACTED_##'.
121
+ """
122
+ if cls.shouldredact:
123
+ stringvalue = re.sub(
124
+ (
125
+ r'(?i)'
126
+ r'((?<=token":\s")|'
127
+ r'(?<=password":\s")|'
128
+ r'(?<=email":\s")|'
129
+ r'(?<=tk":\s")|'
130
+ r'(?<=accountId":\s")|'
131
+ r'(?<=accountID":\s")|'
132
+ r'(?<=authKey":\s")|'
133
+ r'(?<=uuid":\s")|'
134
+ r'(?<=cid":\s")|'
135
+ r'(?<=token\s)|'
136
+ r'(?<=authorizeCode\s)|'
137
+ r'(?<=account_id\s))'
138
+ r'[^"\s]+'
139
+ ),
140
+ '##_REDACTED_##',
141
+ stringvalue,
142
+ )
143
+ return stringvalue
144
+
145
+ @staticmethod
146
+ def is_json(data: str | bytes | None) -> bool:
147
+ """Check if the data is JSON formatted."""
148
+ if data is None:
149
+ return False
150
+ if isinstance(data, str):
151
+ data = data.encode('utf-8')
152
+ try:
153
+ orjson.loads(data)
154
+ except orjson.JSONDecodeError:
155
+ return False
156
+ return True
157
+
158
+ @classmethod
159
+ def api_printer(cls, api: Mapping | bytes | str | None) -> str | None:
160
+ """Print the API dictionary in a readable format."""
161
+ if api is None or len(api) == 0:
162
+ return None
163
+ try:
164
+ if isinstance(api, bytes):
165
+ api_dict = orjson.loads(api)
166
+ elif isinstance(api, str):
167
+ api_dict = orjson.loads(api.encode('utf-8'))
168
+ elif isinstance(api, (dict, CIMultiDictProxy)):
169
+ api_dict = dict(api)
170
+ else:
171
+ return None
172
+ dump = orjson.dumps(
173
+ dict(api_dict), option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS
174
+ )
175
+ return cls.redactor(dump.decode('utf-8'))
176
+
177
+ except (orjson.JSONDecodeError, orjson.JSONEncodeError):
178
+ if isinstance(api, bytes):
179
+ return api.decode('utf-8')
180
+ return str(api)
181
+
182
+ @staticmethod
183
+ def set_log_level(level: str | int = logging.WARNING) -> None:
184
+ """Set log level for logger to INFO."""
185
+ # Test default log levels if int or str
186
+ logging.getLogger().setLevel(level)
187
+
188
+ @staticmethod
189
+ def configure_logger(
190
+ level: str | int = logging.INFO,
191
+ file_name: str | Path | None = None,
192
+ std_out: bool = True,
193
+ ) -> None:
194
+ """Configure pyvesync library logger with a specific log level.
195
+
196
+ Args:
197
+ level (str | int): The log level to set the logger to, can be
198
+ in form of enum `logging.DEBUG` or string `DEBUG`.
199
+ file_name (str | None): The name of the file to log to. If None,
200
+ logs will only be printed to the console.
201
+ std_out (bool): If True, logs will be printed to standard output.
202
+
203
+ Note:
204
+ This method configures the pyvesync base logger and prevents
205
+ propagation of log messages to the root logger to avoid duplicate
206
+ messages.
207
+ """
208
+ if level in (logging.DEBUG, 'DEBUG'):
209
+ LibraryLogger.debug = True
210
+ root_logger = logging.getLogger()
211
+ if root_logger.handlers:
212
+ for handler in root_logger.handlers:
213
+ root_logger.removeHandler(handler)
214
+
215
+ formatter = logging.Formatter(
216
+ fmt='%(asctime)s - %(levelname)s - %(name)s - %(message)s',
217
+ datefmt='%Y-%m-%d %H:%M:%S',
218
+ )
219
+ if std_out is True:
220
+ str_handler = logging.StreamHandler()
221
+ str_handler.setFormatter(formatter)
222
+ root_logger.addHandler(str_handler)
223
+ if isinstance(file_name, str):
224
+ file_name_path = Path(file_name).resolve()
225
+ elif isinstance(file_name, Path):
226
+ file_name_path = file_name.resolve()
227
+ else:
228
+ file_name_path = None
229
+ if file_name_path:
230
+ file_handler = logging.FileHandler(file_name_path)
231
+ file_handler.setFormatter(formatter)
232
+ root_logger.addHandler(file_handler)
233
+ for log_name, logger in root_logger.manager.loggerDict.items():
234
+ if isinstance(logger, logging.Logger) and log_name.startswith('pyvesync'):
235
+ logger.setLevel(level)
236
+
237
+ @classmethod
238
+ def log_mashumaro_response_error(
239
+ cls,
240
+ logger: logging.Logger,
241
+ method_name: str,
242
+ resp_dict: dict,
243
+ exc: MissingField | InvalidFieldValue | UnserializableField,
244
+ device: VeSyncBaseDevice | None = None,
245
+ ) -> None:
246
+ """Log an error parsing API response.
247
+
248
+ Use this log message to indicate that the API response
249
+ is not in the expected format.
250
+
251
+ Args:
252
+ logger (logging.Logger): module logger
253
+ method_name (str): device name
254
+ resp_dict (dict): API response dictionary
255
+ exc (MissingField | InvalidFieldValue | UnserializableField): mashumaro
256
+ exception caught
257
+ device (VeSyncBaseDevice | None): device if a device method was called
258
+ """
259
+ if device is not None:
260
+ msg = (
261
+ f'Error parsing {device.product_type} {device.device_name} {method_name} '
262
+ f'response with data model {exc.holder_class_name}'
263
+ )
264
+ else:
265
+ msg = (
266
+ f'Error parsing {method_name} response with '
267
+ f'data model {exc.holder_class_name}'
268
+ )
269
+ if isinstance(exc, MissingField):
270
+ msg += f'Missing field: {exc.field_name} of type {exc.field_type_name}'
271
+ elif isinstance(exc, InvalidFieldValue):
272
+ msg += f'Invalid field value: {exc.field_name} of type {exc.field_type_name}'
273
+ elif isinstance(exc, UnserializableField):
274
+ msg += f'Unserializable field: {exc.field_name} of type {exc.field_type_name}'
275
+ msg += (
276
+ '\n\n Please report this issue tohttps://github.com/webdjoe/pyvesync/issues'
277
+ )
278
+ logger.warning(msg)
279
+ if not cls.debug:
280
+ return
281
+ msg = ''
282
+ if is_dataclass(exc.holder_class):
283
+ holder = exc.holder_class
284
+ field_tuple = fields(holder)
285
+ resp_f = sorted(resp_dict.keys())
286
+ field_f = sorted([f.name for f in field_tuple])
287
+ dif = set(field_f) - set(resp_f)
288
+ if dif:
289
+ msg += '\n\n-------------------------------------'
290
+ msg += '\n Expected Fields:'
291
+ msg += f'\n({", ".join(field_f)})'
292
+ msg += '\n Response Fields:'
293
+ msg += f'\n({", ".join(resp_f)})'
294
+ msg += '\n Missing Fields:'
295
+ msg += f'\n({", ".join(dif)})'
296
+
297
+ msg += '\n\n Full Response:'
298
+ msg += f'\n{orjson.dumps(resp_dict, option=orjson.OPT_INDENT_2).decode("utf-8")}'
299
+ if cls.verbose:
300
+ msg += '\n\n---------------------------------'
301
+ msg += '\n\n Exception:'
302
+ msg += f'\n{exc.__traceback__}'
303
+ logger.debug(msg)
304
+
305
+ @classmethod
306
+ def log_vs_api_response_error(
307
+ cls,
308
+ logger: logging.Logger,
309
+ method_name: str,
310
+ msg: str | None = None,
311
+ ) -> None:
312
+ """Log an error parsing API response.
313
+
314
+ Use this log message to indicate that the API response
315
+ is not in the expected format.
316
+
317
+ Args:
318
+ logger (logging.Logger): module logger
319
+ method_name (str): device name
320
+ msg (str | None, optional): optional description of error
321
+ """
322
+ logger.debug(
323
+ '%s API returned an unexpected response format: %s',
324
+ method_name,
325
+ msg if msg is not None else '',
326
+ )
327
+
328
+ @classmethod
329
+ def log_device_api_response_error(
330
+ cls,
331
+ logger: logging.Logger,
332
+ device_name: str,
333
+ device_type: str,
334
+ method: str,
335
+ msg: str | None = None,
336
+ ) -> None:
337
+ """Log an error parsing API response.
338
+
339
+ Use this log message to indicate that the API response
340
+ is not in the expected format.
341
+
342
+ Args:
343
+ logger (logging.Logger): module logger
344
+ device_name (str): device name
345
+ device_type (str): device type
346
+ method (str): method that caused the error
347
+ msg (str | None, optional): optional description of error
348
+ """
349
+ logger.debug(
350
+ '%s for %s API returned an unexpected response format in %s: %s',
351
+ device_name,
352
+ device_type,
353
+ method,
354
+ msg if msg is not None else '',
355
+ )
356
+
357
+ @classmethod
358
+ def log_device_return_code(
359
+ cls,
360
+ logger: logging.Logger,
361
+ method: str,
362
+ device_name: str,
363
+ device_type: str,
364
+ code: int,
365
+ message: str = '',
366
+ ) -> None:
367
+ """Log return code from device API call.
368
+
369
+ When API responds with JSON, if the code key is not 0,
370
+ it indicates an error has occured.
371
+
372
+ Args:
373
+ logger (logging.Logger): module logger instance
374
+ method (str): method that caused the error
375
+ device_name (str): device name
376
+ device_type (str): device type
377
+ code (int): api response code
378
+ message (str): api response message
379
+ """
380
+ try:
381
+ code_str = str(code)
382
+ except (TypeError, ValueError):
383
+ code_str = 'UNKNOWN'
384
+ logger.debug(
385
+ '%s for %s API from %s returned code: %s, message: %s',
386
+ device_name,
387
+ device_type,
388
+ method,
389
+ code_str,
390
+ message,
391
+ )
392
+
393
+ @classmethod
394
+ def log_api_call(
395
+ cls,
396
+ logger: logging.Logger,
397
+ response: ClientResponse,
398
+ response_body: bytes | None = None,
399
+ request_body: str | dict | None = None,
400
+ ) -> None:
401
+ """Log API calls in debug mode.
402
+
403
+ Logs an API call with a specific format that includes the endpoint,
404
+ JSON-formatted headers, request body (if any) and response body.
405
+
406
+ Args:
407
+ logger (logging.Logger): The logger instance to use.
408
+ response (aiohttp.ClientResponse): Requests response object from the API call.
409
+ response_body (bytes, optional): The response body to log.
410
+ request_body (dict | str, optional): The request body to log.
411
+
412
+ Notes:
413
+ This is a method used for the logging of API calls when the debug
414
+ flag is enabled. The method logs the endpoint, method, request headers,
415
+ request body (if any), response headers, and response body (if any).
416
+ """
417
+ if cls.verbose is False:
418
+ return
419
+ # Build the log message parts.
420
+ parts = ['========API CALL========']
421
+ endpoint = response.url.path
422
+ parts.append(f'API CALL to endpoint: {endpoint}')
423
+ parts.append(f'Response Status: {response.status}')
424
+ parts.append(f'Method: {response.method}')
425
+
426
+ request_headers = cls.api_printer(response.request_info.headers)
427
+ if request_headers:
428
+ parts.append(f'Request Headers: {os.linesep} {request_headers}')
429
+
430
+ if request_body is not None:
431
+ request_body = cls.api_printer(request_body)
432
+ parts.append(f'Request Body: {os.linesep} {request_body}')
433
+
434
+ response_headers = cls.api_printer(response.headers)
435
+ if response_headers:
436
+ parts.append(f'Response Headers: {os.linesep} {response_headers}')
437
+
438
+ if cls.is_json(response_body):
439
+ response_str = cls.api_printer(response_body)
440
+ parts.append(f'Response Body: {os.linesep} {response_str}')
441
+ elif isinstance(response_body, bytes):
442
+ response_str = response_body.decode('utf-8')
443
+ parts.append(f'Response Body: {os.linesep} {response_str}')
444
+
445
+ full_message = os.linesep.join(parts)
446
+ logger.debug(full_message)
447
+
448
+ @classmethod
449
+ def log_api_status_error(
450
+ cls,
451
+ logger: logging.Logger,
452
+ *,
453
+ request_body: dict | None,
454
+ response: ClientResponse,
455
+ response_bytes: bytes | None,
456
+ ) -> None:
457
+ """Log API exceptions in debug mode.
458
+
459
+ Logs an API call with a specific format that includes the endpoint,
460
+ JSON-formatted headers, request body (if any) and response body.
461
+
462
+ Args:
463
+ logger (logging.Logger): The logger instance to use.
464
+ request_body (dict | None): KW only, The request body to log.
465
+ response (aiohttp.ClientResponse): KW only, dictionary
466
+ containing the request information.
467
+ response_bytes (bytes | None): KW only, The response body to log.
468
+ """
469
+ if cls.debug is False:
470
+ return
471
+ # Build the log message parts.
472
+ parts = [f'Error in API CALL to endpoint: {response.url.path}']
473
+ parts.append(f'Response Status: {response.status}')
474
+ req_headers = cls.api_printer(response.request_info.headers)
475
+ if req_headers is not None:
476
+ parts.append(f'Request Headers: {os.linesep} {req_headers}')
477
+
478
+ req_body = cls.api_printer(request_body)
479
+ if req_body is not None:
480
+ parts.append(f'Request Body: {os.linesep} {req_body}')
481
+
482
+ resp_headers = cls.api_printer(response.headers)
483
+ if resp_headers is not None:
484
+ parts.append(f'Response Headers: {os.linesep} {resp_headers}')
485
+
486
+ resp_body = cls.api_printer(response_bytes)
487
+ if resp_body is not None:
488
+ parts.append(f'Request Body: {os.linesep} {request_body}')
489
+
490
+ full_message = os.linesep.join(parts)
491
+ logger.debug(full_message)
492
+
493
+ @classmethod
494
+ def log_api_exception(
495
+ cls,
496
+ logger: logging.Logger,
497
+ *,
498
+ exception: ClientResponseError,
499
+ request_body: dict | None,
500
+ ) -> None:
501
+ """Log API exceptions in debug mode.
502
+
503
+ Logs an API call with a specific format that includes the endpoint,
504
+ JSON-formatted headers, request body (if any) and response body.
505
+
506
+ Args:
507
+ logger (logging.Logger): The logger instance to use.
508
+ exception (ClientResponseError): KW only, The request body to log.
509
+ request_body (dict | None): KW only, The request body.
510
+ """
511
+ if cls.debug is False:
512
+ return
513
+ # Build the log message parts.
514
+ parts = [f'Error in API CALL to endpoint: {exception.request_info.url.path}']
515
+ parts.append(f'Exception Raised: {exception}')
516
+ req_headers = cls.api_printer(exception.request_info.headers)
517
+ if req_headers is not None:
518
+ parts.append(f'Request Headers: {os.linesep} {req_headers}')
519
+
520
+ req_body = cls.api_printer(request_body)
521
+ if req_body is not None:
522
+ parts.append(f'Request Body: {os.linesep} {req_body}')
523
+
524
+ full_message = os.linesep.join(parts)
525
+ logger.debug(full_message)