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