zscaler-sdk-python 1.0.0__py2.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 (75) hide show
  1. zscaler/__init__.py +34 -0
  2. zscaler/cache/__init__.py +0 -0
  3. zscaler/cache/cache.py +105 -0
  4. zscaler/cache/no_op_cache.py +68 -0
  5. zscaler/cache/zscaler_cache.py +161 -0
  6. zscaler/constants.py +26 -0
  7. zscaler/errors/__init__.py +0 -0
  8. zscaler/errors/error.py +10 -0
  9. zscaler/errors/http_error.py +20 -0
  10. zscaler/errors/zscaler_api_error.py +24 -0
  11. zscaler/exceptions/__init__.py +1 -0
  12. zscaler/exceptions/exceptions.py +101 -0
  13. zscaler/logger.py +57 -0
  14. zscaler/ratelimiter/__init__.py +0 -0
  15. zscaler/ratelimiter/ratelimiter.py +39 -0
  16. zscaler/user_agent.py +23 -0
  17. zscaler/utils.py +577 -0
  18. zscaler/zia/__init__.py +657 -0
  19. zscaler/zia/activate.py +52 -0
  20. zscaler/zia/admin_and_role_management.py +344 -0
  21. zscaler/zia/apptotal.py +71 -0
  22. zscaler/zia/audit_logs.py +95 -0
  23. zscaler/zia/authentication_settings.py +98 -0
  24. zscaler/zia/client.py +88 -0
  25. zscaler/zia/cloud_apps.py +406 -0
  26. zscaler/zia/device_management.py +90 -0
  27. zscaler/zia/dlp.py +784 -0
  28. zscaler/zia/errors.py +37 -0
  29. zscaler/zia/firewall.py +1104 -0
  30. zscaler/zia/forwarding_control.py +271 -0
  31. zscaler/zia/isolation_profile.py +83 -0
  32. zscaler/zia/labels.py +180 -0
  33. zscaler/zia/locations.py +661 -0
  34. zscaler/zia/sandbox.py +180 -0
  35. zscaler/zia/security.py +236 -0
  36. zscaler/zia/ssl_inspection.py +175 -0
  37. zscaler/zia/traffic.py +853 -0
  38. zscaler/zia/url_categories.py +442 -0
  39. zscaler/zia/url_filtering.py +310 -0
  40. zscaler/zia/users.py +386 -0
  41. zscaler/zia/web_dlp.py +295 -0
  42. zscaler/zia/workload_groups.py +58 -0
  43. zscaler/zia/zpa_gateway.py +187 -0
  44. zscaler/zpa/__init__.py +683 -0
  45. zscaler/zpa/app_segments.py +331 -0
  46. zscaler/zpa/app_segments_inspection.py +311 -0
  47. zscaler/zpa/app_segments_pra.py +310 -0
  48. zscaler/zpa/certificates.py +234 -0
  49. zscaler/zpa/client.py +113 -0
  50. zscaler/zpa/cloud_connector_groups.py +75 -0
  51. zscaler/zpa/connectors.py +518 -0
  52. zscaler/zpa/emergency_access.py +178 -0
  53. zscaler/zpa/errors.py +37 -0
  54. zscaler/zpa/idp.py +83 -0
  55. zscaler/zpa/inspection.py +1012 -0
  56. zscaler/zpa/isolation_profile.py +85 -0
  57. zscaler/zpa/lss.py +568 -0
  58. zscaler/zpa/machine_groups.py +79 -0
  59. zscaler/zpa/policies.py +848 -0
  60. zscaler/zpa/posture_profiles.py +122 -0
  61. zscaler/zpa/privileged_remote_access.py +862 -0
  62. zscaler/zpa/provisioning.py +271 -0
  63. zscaler/zpa/saml_attributes.py +100 -0
  64. zscaler/zpa/scim_attributes.py +117 -0
  65. zscaler/zpa/scim_groups.py +146 -0
  66. zscaler/zpa/segment_groups.py +191 -0
  67. zscaler/zpa/server_groups.py +217 -0
  68. zscaler/zpa/servers.py +202 -0
  69. zscaler/zpa/service_edges.py +404 -0
  70. zscaler/zpa/trusted_networks.py +127 -0
  71. zscaler_sdk_python-1.0.0.dist-info/LICENSE.md +21 -0
  72. zscaler_sdk_python-1.0.0.dist-info/METADATA +59 -0
  73. zscaler_sdk_python-1.0.0.dist-info/RECORD +75 -0
  74. zscaler_sdk_python-1.0.0.dist-info/WHEEL +6 -0
  75. zscaler_sdk_python-1.0.0.dist-info/top_level.txt +1 -0
zscaler/utils.py ADDED
@@ -0,0 +1,577 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ # Copyright (c) 2023, Zscaler Inc.
4
+ #
5
+ # Permission to use, copy, modify, and/or distribute this software for any
6
+ # purpose with or without fee is hereby granted, provided that the above
7
+ # copyright notice and this permission notice appear in all copies.
8
+ #
9
+ # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16
+
17
+ import argparse
18
+ import base64
19
+ import datetime
20
+ import json
21
+ import json as jsonp
22
+ import logging
23
+ import random
24
+ import re
25
+ import time
26
+ from typing import Dict, Optional
27
+ from urllib.parse import urlencode
28
+
29
+ import pytz
30
+ from box import Box, BoxList
31
+ from dateutil import parser
32
+ from requests import Response
33
+ from restfly import APIIterator
34
+
35
+ from zscaler.constants import RETRYABLE_STATUS_CODES
36
+
37
+ logger = logging.getLogger("zscaler-sdk-python")
38
+
39
+
40
+ # Recursive function to convert all keys and nested keys from camel case
41
+ # to snake case.
42
+ def convert_keys_to_snake(data):
43
+ if isinstance(data, (list, BoxList)):
44
+ return [convert_keys_to_snake(inner_dict) for inner_dict in data]
45
+ elif isinstance(data, (dict, Box)):
46
+ new_dict = {}
47
+ for k in data.keys():
48
+ v = data[k]
49
+ new_key = camel_to_snake(k)
50
+ new_dict[new_key] = (
51
+ convert_keys_to_snake(v) if isinstance(v, (dict, list)) else v
52
+ )
53
+ return new_dict
54
+ else:
55
+ return data
56
+
57
+
58
+ def camel_to_snake(name: str):
59
+ """Converts Python camelCase to Zscaler's lower snake_case."""
60
+ # Edge-cases where camelCase is breaking
61
+ edge_cases = {
62
+ "routableIP": "routable_ip",
63
+ "isNameL10nTag": "is_name_l10n_tag",
64
+ "nameL10nTag": "name_l10n_tag",
65
+ "surrogateIP": "surrogate_ip",
66
+ "surrogateIPEnforcedForKnownBrowsers": "surrogate_ip_enforced_for_known_browsers",
67
+ "startIPAddress": "start_ip_address",
68
+ "endIPAddress": "end_ip_address",
69
+ "isIncompleteDRConfig": "is_incomplete_dr_config",
70
+ }
71
+ return edge_cases.get(name, re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower())
72
+
73
+
74
+ def snake_to_camel(name: str):
75
+ """Converts Python Snake Case to Zscaler's lower camelCase."""
76
+ if "_" not in name:
77
+ return name
78
+ # Edge-cases where camelCase is breaking
79
+ edge_cases = {
80
+ "routable_ip": "routableIP",
81
+ "is_name_l10n_tag": "isNameL10nTag",
82
+ "name_l10n_tag": "nameL10nTag",
83
+ "surrogate_ip": "surrogateIP",
84
+ "surrogate_ip_enforced_for_known_browsers": "surrogateIPEnforcedForKnownBrowsers",
85
+ "is_incomplete_dr_config": "isIncompleteDRConfig",
86
+ "email_ids": "emailIds",
87
+ "page_size": "pageSize",
88
+ }
89
+ return edge_cases.get(name, name[0].lower() + name.title()[1:].replace("_", ""))
90
+
91
+
92
+ def recursive_snake_to_camel(data):
93
+ """Recursively convert dictionary keys from snake_case to camelCase."""
94
+ if isinstance(data, dict):
95
+ return {
96
+ snake_to_camel(key): recursive_snake_to_camel(value)
97
+ for key, value in data.items()
98
+ }
99
+ elif isinstance(data, list):
100
+ return [recursive_snake_to_camel(item) for item in data]
101
+ else:
102
+ return data
103
+
104
+
105
+ def chunker(lst, n):
106
+ """Yield successive n-sized chunks from lst."""
107
+ for i in range(0, len(lst), n):
108
+ yield lst[i : i + n]
109
+
110
+
111
+ # Recursive function to convert all keys and nested keys from snake case
112
+ # to camel case.
113
+
114
+
115
+ def convert_keys(data):
116
+ if isinstance(data, (list, BoxList)):
117
+ return [convert_keys(inner_dict) for inner_dict in data]
118
+ elif isinstance(data, (dict, Box)):
119
+ new_dict = {}
120
+ for k in data.keys():
121
+ v = data[k]
122
+ new_key = snake_to_camel(k)
123
+ new_dict[new_key] = convert_keys(v) if isinstance(v, (dict, list)) else v
124
+ return new_dict
125
+ else:
126
+ return data
127
+
128
+
129
+ def keys_exists(element: dict, *keys):
130
+ """
131
+ Check if *keys (nested) exists in `element` (dict).
132
+ """
133
+ if not isinstance(element, dict):
134
+ raise AttributeError("keys_exists() expects dict as first argument.")
135
+ if not keys:
136
+ raise AttributeError("keys_exists() expects at least two arguments, one given.")
137
+
138
+ _element = element
139
+ for key in keys:
140
+ try:
141
+ _element = _element[key]
142
+ except KeyError:
143
+ return False
144
+ return True
145
+
146
+
147
+ # Takes a tuple if id_groups, kwargs and the payload dict; reformat for API call
148
+ def add_id_groups(id_groups: list, kwargs: dict, payload: dict):
149
+ for entry in id_groups:
150
+ if kwargs.get(entry[0]):
151
+ payload[entry[1]] = [{"id": param_id} for param_id in kwargs.pop(entry[0])]
152
+ return
153
+
154
+
155
+ def transform_common_id_fields(id_groups: list, kwargs: dict, payload: dict):
156
+ for entry in id_groups:
157
+ if kwargs.get(entry[0]):
158
+ # Ensure each ID is treated as an integer before adding it to the payload
159
+ payload[entry[1]] = [
160
+ {"id": int(param_id)} for param_id in kwargs.pop(entry[0])
161
+ ]
162
+ return
163
+
164
+
165
+ def transform_clientless_apps(clientless_app_ids):
166
+ transformed_apps = []
167
+ for app in clientless_app_ids:
168
+ # Transform each attribute in app as needed by your API
169
+ transformed_apps.append(
170
+ {
171
+ "name": app["name"],
172
+ "applicationProtocol": app["application_protocol"],
173
+ "applicationPort": app["application_port"],
174
+ "certificateId": app["certificate_id"],
175
+ "trustUntrustedCert": app["trust_untrusted_cert"],
176
+ "enabled": app["enabled"],
177
+ "domain": app["domain"],
178
+ }
179
+ )
180
+ return transformed_apps
181
+
182
+
183
+ def format_clientless_apps(clientless_apps):
184
+ # Implement this function to format clientless_apps as needed for the update request
185
+ # This is just a placeholder example
186
+ formatted_apps = []
187
+ for app in clientless_apps:
188
+ formatted_app = {
189
+ "id": app["id"], # use the correct key
190
+ # Add other necessary attributes and format them as needed
191
+ }
192
+ formatted_apps.append(formatted_app)
193
+ return formatted_apps
194
+
195
+
196
+ def obfuscate_api_key(seed: list):
197
+ now = int(time.time() * 1000)
198
+ n = str(now)[-6:]
199
+ r = str(int(n) >> 1).zfill(6)
200
+ key = "".join(seed[int(str(n)[i])] for i in range(len(str(n))))
201
+ for j in range(len(r)):
202
+ key += seed[int(r[j]) + 2]
203
+
204
+ return {"timestamp": now, "key": key}
205
+
206
+
207
+ def format_json_response(
208
+ response: Response,
209
+ box_attrs: Optional[Dict] = None,
210
+ conv_json: bool = True,
211
+ conv_box: bool = True,
212
+ ):
213
+ """
214
+ A simple utility to handle formatting the response object into either a
215
+ Box object or a Python native object from the JSON response. The function
216
+ will prefer box over python native if both flags are set to true. If none
217
+ of the flags are true, or if the content-type header reports as something
218
+ other than "applicagion/json", then the response object is instead
219
+ returned.
220
+
221
+ Args:
222
+ response:
223
+ The response object that will be checked against.
224
+ box_attrs:
225
+ The optional box attributed to pass as part of instantiation.
226
+ conv_json:
227
+ A flag handling if we should run the JSON conversion to python
228
+ native datatypes.
229
+ conv_box:
230
+ A flaghandling if we should convert the data to a Box object.
231
+
232
+ Returns:
233
+ box.Box:
234
+ If the conv_box flag is True, and the response is a single object,
235
+ then the response is a Box obj.
236
+ box.BoxList:
237
+ If the conv_box flag is True, and the response is a list of
238
+ objects, then the response is a BoxList obj.
239
+ dict:
240
+ If the conv_json flag is True and the conv_box is False, and the
241
+ response is a single object, then the response is a dict obj.
242
+ list:
243
+ If the conv_json flag is True and conv_box is False, and the
244
+ response is a list of objects, then the response is a list obj.
245
+ requests.Response:
246
+ If neither flag is True, or if the response isn't JSON data, then
247
+ a response object is returned (pass-through).
248
+ """
249
+ if response.status_code > 299:
250
+ return response
251
+ content_type = response.headers.get("content-type", "application/json")
252
+ if (
253
+ (conv_json or conv_box)
254
+ and "application/json" in content_type.lower()
255
+ and len(response.text) > 0
256
+ ): # noqa: E124
257
+ if conv_box:
258
+ data = convert_keys_to_snake(response.json())
259
+ if isinstance(data, list):
260
+ return BoxList(data, **box_attrs)
261
+ elif isinstance(data, dict):
262
+ return Box(data, **box_attrs)
263
+ elif conv_json:
264
+ return convert_keys_to_snake(response.json())
265
+ return response
266
+
267
+
268
+ def pick_version_profile(kwargs: list, payload: list):
269
+ # Used in ZPA endpoints.
270
+ # This function is used to convert the name of the version profile to
271
+ # the version profile id. This means our users don't need to look up the
272
+ # version profile id mapping themselves.
273
+
274
+ version_profile = kwargs.pop("version_profile", None)
275
+ if version_profile:
276
+ payload["overrideVersionProfile"] = True
277
+ if version_profile == "default":
278
+ payload["versionProfileId"] = 0
279
+ elif version_profile == "previous_default":
280
+ payload["versionProfileId"] = 1
281
+ elif version_profile == "new_release":
282
+ payload["versionProfileId"] = 2
283
+
284
+
285
+ class Iterator(APIIterator):
286
+ """Iterator class."""
287
+
288
+ page_size = 100
289
+
290
+ def __init__(self, api, path: str = "", **kw):
291
+ """Initialize Iterator class."""
292
+ super().__init__(api, **kw)
293
+
294
+ self.path = path
295
+ self.max_items = kw.pop("max_items", 0)
296
+ self.max_pages = kw.pop("max_pages", 0)
297
+ self.payload = {}
298
+ if kw:
299
+ self.payload = {snake_to_camel(key): value for key, value in kw.items()}
300
+
301
+ def _get_page(self) -> None:
302
+ """Iterator function to get the page."""
303
+ resp = self._api.get(
304
+ self.path,
305
+ params={**self.payload, "page": self.num_pages + 1},
306
+ )
307
+ try:
308
+ # If we are using ZPA then the API will return records under the
309
+ # 'list' key.
310
+ self.page = resp.get("list") or []
311
+ except AttributeError:
312
+ # If the list key doesn't exist then we're likely using ZIA so just
313
+ # return the full response.
314
+ self.page = resp
315
+ finally:
316
+ # If we use the default retry-after logic in Restfly then we are
317
+ # going to keep seeing 429 messages in stdout. ZIA and ZPA have a
318
+ # standard 1 sec rate limit on the API endpoints with pagination so
319
+ # we are going to include it here.
320
+ time.sleep(1)
321
+
322
+
323
+ def remove_cloud_suffix(str_name: str) -> str:
324
+ """
325
+ Removes appended cloud name (e.g. "(zscalerthree.net)") from the string.
326
+
327
+ Args:
328
+ str_name (str): The string from which to remove the cloud name.
329
+
330
+ Returns:
331
+ str: The string without the cloud name.
332
+ """
333
+ reg = re.compile(r"(.*)\s+\([a-zA-Z0-9\-_\.]*\)\s*$")
334
+ res = reg.sub(r"\1", str_name)
335
+ return res.strip()
336
+
337
+
338
+ def should_retry(status_code):
339
+ """Determine if a given status code should be retried."""
340
+ return status_code in RETRYABLE_STATUS_CODES
341
+
342
+
343
+ def retry_with_backoff(method_type="GET", retries=5, backoff_in_seconds=0.5):
344
+ """
345
+ Decorator to retry a function in case of an unsuccessful response.
346
+
347
+ Parameters:
348
+ - method_type (str): The HTTP method. Defaults to "GET".
349
+ - retries (int): Number of retries before giving up. Defaults to 5.
350
+ - backoff_in_seconds (float): Initial wait time (in seconds) before retry. Defaults to 0.5.
351
+
352
+ Returns:
353
+ - function: Decorated function with retry and backoff logic.
354
+ """
355
+
356
+ if method_type != "GET":
357
+ retries = min(retries, 3) # more conservative retry count for non-GET
358
+
359
+ def decorator(f):
360
+ def wrapper(*args, **kwargs):
361
+ x = 0
362
+ while True:
363
+ resp = f(*args, **kwargs)
364
+
365
+ # Check if it's a successful status code, 400, or if it shouldn't be retried
366
+ if (
367
+ 299 >= resp.status_code >= 200
368
+ or resp.status_code == 400
369
+ or not should_retry(resp.status_code)
370
+ ):
371
+ return resp
372
+
373
+ if x == retries:
374
+ try:
375
+ error_msg = resp.json()
376
+ except Exception as e:
377
+ error_msg = str(e)
378
+ raise Exception(f"Reached max retries. Response: {error_msg}")
379
+ else:
380
+ sleep = backoff_in_seconds * 2**x + random.uniform(0, 1)
381
+ logger.info(
382
+ "Args: %s, retrying after %d seconds...", str(args), sleep
383
+ )
384
+ time.sleep(sleep)
385
+ x += 1
386
+
387
+ return wrapper
388
+
389
+ return decorator
390
+
391
+
392
+ def is_token_expired(token_string):
393
+ # If token string is None or empty, consider it expired
394
+ if not token_string:
395
+ logger.warning("Token string is None or empty. Requesting a new token.")
396
+ return True
397
+
398
+ try:
399
+ # Split the token into its parts
400
+ parts = token_string.split(".")
401
+ if len(parts) != 3:
402
+ return True
403
+
404
+ # Decode the payload
405
+ payload_bytes = base64.urlsafe_b64decode(
406
+ parts[1] + "=="
407
+ ) # Padding might be needed
408
+ payload = jsonp.loads(payload_bytes)
409
+
410
+ # Check expiration time
411
+ if "exp" in payload:
412
+ # Deduct 10 seconds to account for any possible latency or clock skew
413
+ expiration_time = payload["exp"] - 10
414
+ if time.time() > expiration_time:
415
+ return True
416
+
417
+ return False
418
+
419
+ except Exception as e:
420
+ logger.error(f"Error checking token expiration: {str(e)}")
421
+ return True
422
+
423
+
424
+ def str2bool(v):
425
+ if isinstance(v, bool):
426
+ return v
427
+ if v.lower() in ("yes", "true", "t", "y", "1"):
428
+ return True
429
+ elif v.lower() in ("no", "false", "f", "n", "0"):
430
+ return False
431
+ else:
432
+ raise argparse.ArgumentTypeError("Boolean value expected.")
433
+
434
+
435
+ def is_valid_ssh_key(private_key: str) -> bool:
436
+ """
437
+ Validate SSH private key format.
438
+ """
439
+ # Basic pattern matching to check for RSA/ECDSA (OpenSSH/PEM) key headers
440
+ ssh_key_patterns = [
441
+ r"-----BEGIN OPENSSH PRIVATE KEY-----",
442
+ r"-----BEGIN RSA PRIVATE KEY-----",
443
+ r"-----BEGIN EC PRIVATE KEY-----",
444
+ ]
445
+ return any(re.search(pattern, private_key) for pattern in ssh_key_patterns)
446
+
447
+
448
+ def validate_and_convert_times(start_time_str, end_time_str, time_zone_str):
449
+ """
450
+ Validates and converts provided time strings to epoch.
451
+ Validates the time zone against IANA Time Zone database.
452
+ Ensures start time is not more than 1 hour in the past and within 1 year range of end time.
453
+
454
+ Args:
455
+ start_time_str (str): Start time in RFC1123Z or RFC1123 format.
456
+ end_time_str (str): End time in RFC1123Z or RFC1123 format.
457
+ time_zone_str (str): IANA Time Zone database string.
458
+
459
+ Returns:
460
+ tuple: Converted start and end times in epoch format.
461
+
462
+ Raises:
463
+ ValueError: If any validation fails.
464
+ """
465
+ # Validate time zone
466
+ if time_zone_str not in pytz.all_timezones:
467
+ raise ValueError(f"Invalid time zone: {time_zone_str}")
468
+
469
+ # Convert times
470
+ try:
471
+ start_time = parser.parse(start_time_str)
472
+ end_time = parser.parse(end_time_str)
473
+ except ValueError as e:
474
+ raise ValueError(f"Time parsing error: {e}")
475
+
476
+ # Handle timezone conversion
477
+ tz = pytz.timezone(time_zone_str)
478
+ if start_time.tzinfo is not None:
479
+ start_time = start_time.astimezone(tz)
480
+ else:
481
+ start_time = tz.localize(start_time)
482
+
483
+ if end_time.tzinfo is not None:
484
+ end_time = end_time.astimezone(tz)
485
+ else:
486
+ end_time = tz.localize(end_time)
487
+
488
+ # Ensure start time is not more than 1 hour in the past
489
+ now_in_tz = datetime.datetime.now(tz)
490
+ if start_time < (now_in_tz - datetime.timedelta(hours=1)):
491
+ raise ValueError("Start time cannot be more than 1 hour in the past.")
492
+
493
+ # Ensure start time is within a one year range of end time
494
+ if end_time > (start_time + datetime.timedelta(days=365)):
495
+ raise ValueError("Start time and end time range cannot exceed 1 year.")
496
+
497
+ # Convert to epoch
498
+ start_epoch = int(start_time.timestamp())
499
+ end_epoch = int(end_time.timestamp())
500
+
501
+ return start_epoch, end_epoch
502
+
503
+
504
+ def dump_request(
505
+ logger, url: str, method: str, json, params, headers, request_uuid: str, body=True
506
+ ):
507
+ request_headers_filtered = {
508
+ key: value for key, value in headers.items() if key != "Authorization"
509
+ }
510
+ # Log the request details before sending the request
511
+ request_data = {
512
+ "url": url,
513
+ "method": method,
514
+ "params": jsonp.dumps(params),
515
+ "uuid": str(request_uuid),
516
+ "request_headers": jsonp.dumps(request_headers_filtered),
517
+ }
518
+ log_lines = []
519
+ request_body = ""
520
+ if body:
521
+ request_body = jsonp.dumps(json)
522
+ log_lines.append(
523
+ f"\n---[ ZSCALER SDK REQUEST | ID:{request_uuid} ]-------------------------------"
524
+ )
525
+ log_lines.append(f"{method} {url}")
526
+ for key, value in headers.items():
527
+ log_lines.append(f"{key}: {value}")
528
+ if body and request_body != "" and request_body != "null":
529
+ log_lines.append(f"\n{request_body}")
530
+ log_lines.append(
531
+ "--------------------------------------------------------------------"
532
+ )
533
+ logger.info("\n".join(log_lines))
534
+
535
+
536
+ def dump_response(
537
+ logger,
538
+ url: str,
539
+ method: str,
540
+ resp,
541
+ params,
542
+ request_uuid: str,
543
+ start_time,
544
+ from_cache: bool = None,
545
+ ):
546
+ # Calculate the duration in seconds
547
+ end_time = time.time()
548
+ duration_seconds = end_time - start_time
549
+ # Convert the duration to milliseconds
550
+ duration_ms = duration_seconds * 1000
551
+ # Convert the headers to a regular dictionary
552
+ response_headers_dict = dict(resp.headers)
553
+ full_url = url
554
+ if params:
555
+ full_url += "?" + urlencode(params)
556
+ log_lines = []
557
+ response_body = ""
558
+ if resp.text:
559
+ response_body = resp.text
560
+
561
+ if from_cache:
562
+ log_lines.append(
563
+ f"\n---[ ZSCALER SDK RESPONSE | ID:{request_uuid} | "
564
+ f"FROM CACHE | DURATION:{duration_ms}ms ]" + "-" * 31
565
+ )
566
+ else:
567
+ log_lines.append(
568
+ f"\n---[ ZSCALER SDK RESPONSE | ID:{request_uuid} | "
569
+ f"DURATION:{duration_ms}ms ]" + "-" * 46
570
+ )
571
+ log_lines.append(f"{method} {full_url}")
572
+ for key, value in response_headers_dict.items():
573
+ log_lines.append(f"{key}: {value}")
574
+ if response_body and response_body != "" and response_body != "null":
575
+ log_lines.append(f"\n{response_body}")
576
+ log_lines.append("-" * 68)
577
+ logger.info("\n".join(log_lines))