sfq 0.0.14__tar.gz → 0.0.16__tar.gz
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.
- {sfq-0.0.14 → sfq-0.0.16}/PKG-INFO +8 -1
- {sfq-0.0.14 → sfq-0.0.16}/README.md +7 -0
- {sfq-0.0.14 → sfq-0.0.16}/pyproject.toml +1 -1
- {sfq-0.0.14 → sfq-0.0.16}/src/sfq/__init__.py +228 -525
- sfq-0.0.16/src/sfq/_cometd.py +297 -0
- {sfq-0.0.14 → sfq-0.0.16}/uv.lock +1 -1
- {sfq-0.0.14 → sfq-0.0.16}/.github/workflows/publish.yml +0 -0
- {sfq-0.0.14 → sfq-0.0.16}/.gitignore +0 -0
- {sfq-0.0.14 → sfq-0.0.16}/.python-version +0 -0
- {sfq-0.0.14 → sfq-0.0.16}/src/sfq/py.typed +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: sfq
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.16
|
4
4
|
Summary: Python wrapper for the Salesforce's Query API.
|
5
5
|
Author-email: David Moruzzi <sfq.pypi@dmoruzi.com>
|
6
6
|
Keywords: salesforce,salesforce query
|
@@ -90,6 +90,13 @@ for subrequest_identifer, subrequest_response in batched_response.items():
|
|
90
90
|
>>> "Frozen Users" returned 4082 records
|
91
91
|
```
|
92
92
|
|
93
|
+
### Collection Deletions
|
94
|
+
|
95
|
+
```python
|
96
|
+
response = sf.cdelete(['07La0000000bYgj', '07La0000000bYgk', '07La0000000bYgl'])
|
97
|
+
>>> [{'id': '500aj000006wtdZAAQ', 'success': True, 'errors': []}, {'id': '500aj000006wtdaAAA', 'success': True, 'errors': []}, {'id': '500aj000006wtdbAAA', 'success': True, 'errors': []}]
|
98
|
+
```
|
99
|
+
|
93
100
|
### Static Resources
|
94
101
|
|
95
102
|
```python
|
@@ -74,6 +74,13 @@ for subrequest_identifer, subrequest_response in batched_response.items():
|
|
74
74
|
>>> "Frozen Users" returned 4082 records
|
75
75
|
```
|
76
76
|
|
77
|
+
### Collection Deletions
|
78
|
+
|
79
|
+
```python
|
80
|
+
response = sf.cdelete(['07La0000000bYgj', '07La0000000bYgk', '07La0000000bYgl'])
|
81
|
+
>>> [{'id': '500aj000006wtdZAAQ', 'success': True, 'errors': []}, {'id': '500aj000006wtdaAAA', 'success': True, 'errors': []}, {'id': '500aj000006wtdbAAA', 'success': True, 'errors': []}]
|
82
|
+
```
|
83
|
+
|
77
84
|
### Static Resources
|
78
85
|
|
79
86
|
```python
|
@@ -7,18 +7,13 @@ import time
|
|
7
7
|
import warnings
|
8
8
|
from collections import OrderedDict
|
9
9
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
10
|
-
from
|
11
|
-
from typing import Any, Dict, Literal, Optional
|
10
|
+
from typing import Any, Dict, Iterable, Literal, Optional, List, Tuple
|
12
11
|
from urllib.parse import quote, urlparse
|
13
12
|
|
14
13
|
TRACE = 5
|
15
14
|
logging.addLevelName(TRACE, "TRACE")
|
16
15
|
|
17
16
|
|
18
|
-
class ExperimentalWarning(Warning):
|
19
|
-
pass
|
20
|
-
|
21
|
-
|
22
17
|
def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
|
23
18
|
"""Custom TRACE level logging function with redaction."""
|
24
19
|
|
@@ -31,6 +26,7 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
|
|
31
26
|
"set-cookie",
|
32
27
|
"cookie",
|
33
28
|
"refresh_token",
|
29
|
+
"client_secret",
|
34
30
|
]
|
35
31
|
if isinstance(data, dict):
|
36
32
|
return {
|
@@ -81,15 +77,15 @@ class SFAuth:
|
|
81
77
|
self,
|
82
78
|
instance_url: str,
|
83
79
|
client_id: str,
|
84
|
-
refresh_token: str,
|
80
|
+
refresh_token: str, # client_secret & refresh_token will swap positions 2025-AUG-1
|
85
81
|
client_secret: str = "_deprecation_warning", # mandatory after 2025-AUG-1
|
86
82
|
api_version: str = "v63.0",
|
87
83
|
token_endpoint: str = "/services/oauth2/token",
|
88
84
|
access_token: Optional[str] = None,
|
89
85
|
token_expiration_time: Optional[float] = None,
|
90
86
|
token_lifetime: int = 15 * 60,
|
91
|
-
user_agent: str = "sfq/0.0.
|
92
|
-
sforce_client: str =
|
87
|
+
user_agent: str = "sfq/0.0.16",
|
88
|
+
sforce_client: str = "_auto",
|
93
89
|
proxy: str = "auto",
|
94
90
|
) -> None:
|
95
91
|
"""
|
@@ -104,7 +100,7 @@ class SFAuth:
|
|
104
100
|
:param access_token: The access token for the current session (default is None).
|
105
101
|
:param token_expiration_time: The expiration time of the access token (default is None).
|
106
102
|
:param token_lifetime: The lifetime of the access token in seconds (default is 15 minutes).
|
107
|
-
:param user_agent: Custom User-Agent string (default is "sfq/0.0.
|
103
|
+
:param user_agent: Custom User-Agent string (default is "sfq/0.0.16").
|
108
104
|
:param sforce_client: Custom Application Identifier (default is user_agent).
|
109
105
|
:param proxy: The proxy configuration, "auto" to use environment (default is "auto").
|
110
106
|
"""
|
@@ -118,12 +114,12 @@ class SFAuth:
|
|
118
114
|
self.token_expiration_time = token_expiration_time
|
119
115
|
self.token_lifetime = token_lifetime
|
120
116
|
self.user_agent = user_agent
|
121
|
-
self.sforce_client = sforce_client
|
117
|
+
self.sforce_client = quote(str(sforce_client), safe="")
|
122
118
|
self._auto_configure_proxy(proxy)
|
123
119
|
self._high_api_usage_threshold = 80
|
124
120
|
|
125
|
-
if sforce_client ==
|
126
|
-
self.sforce_client = user_agent
|
121
|
+
if sforce_client == "_auto":
|
122
|
+
self.sforce_client = quote(str(user_agent), safe="")
|
127
123
|
|
128
124
|
if self.client_secret == "_deprecation_warning":
|
129
125
|
warnings.warn(
|
@@ -138,7 +134,6 @@ class SFAuth:
|
|
138
134
|
)
|
139
135
|
|
140
136
|
def _format_instance_url(self, instance_url) -> str:
|
141
|
-
# check if it begins with https://
|
142
137
|
if instance_url.startswith("https://"):
|
143
138
|
return instance_url
|
144
139
|
if instance_url.startswith("http://"):
|
@@ -205,49 +200,72 @@ class SFAuth:
|
|
205
200
|
logger.trace("Direct connection to %s", netloc)
|
206
201
|
return conn
|
207
202
|
|
208
|
-
def
|
203
|
+
def _send_request(
|
204
|
+
self,
|
205
|
+
method: str,
|
206
|
+
endpoint: str,
|
207
|
+
headers: Dict[str, str],
|
208
|
+
body: Optional[str] = None,
|
209
|
+
) -> Tuple[Optional[int], Optional[str]]:
|
209
210
|
"""
|
210
|
-
|
211
|
+
Unified request method with built-in logging and error handling.
|
211
212
|
|
212
|
-
:param
|
213
|
-
:
|
213
|
+
:param method: HTTP method to use.
|
214
|
+
:param endpoint: Target API endpoint.
|
215
|
+
:param headers: HTTP headers.
|
216
|
+
:param body: Optional request body.
|
217
|
+
:param timeout: Optional timeout in seconds.
|
218
|
+
:return: Tuple of HTTP status code and response body as a string.
|
214
219
|
"""
|
215
220
|
parsed_url = urlparse(self.instance_url)
|
216
221
|
conn = self._create_connection(parsed_url.netloc)
|
217
|
-
headers = {
|
218
|
-
"Accept": "application/json",
|
219
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
220
|
-
"User-Agent": self.user_agent,
|
221
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
222
|
-
}
|
223
|
-
body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
224
222
|
|
225
223
|
try:
|
226
|
-
logger.trace("Request
|
227
|
-
logger.trace("Request
|
224
|
+
logger.trace("Request method: %s", method)
|
225
|
+
logger.trace("Request endpoint: %s", endpoint)
|
228
226
|
logger.trace("Request headers: %s", headers)
|
229
|
-
|
227
|
+
if body:
|
228
|
+
logger.trace("Request body: %s", body)
|
229
|
+
|
230
|
+
conn.request(method, endpoint, body=body, headers=headers)
|
230
231
|
response = conn.getresponse()
|
231
|
-
data = response.read().decode("utf-8")
|
232
232
|
self._http_resp_header_logic(response)
|
233
233
|
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
logger.error(
|
240
|
-
"Token refresh failed: %s %s", response.status, response.reason
|
241
|
-
)
|
242
|
-
logger.debug("Response body: %s", data)
|
234
|
+
data = response.read().decode("utf-8")
|
235
|
+
logger.trace("Response status: %s", response.status)
|
236
|
+
logger.trace("Response body: %s", data)
|
237
|
+
return response.status, data
|
243
238
|
|
244
239
|
except Exception as err:
|
245
|
-
logger.exception("
|
240
|
+
logger.exception("HTTP request failed: %s", err)
|
241
|
+
return None, None
|
246
242
|
|
247
243
|
finally:
|
248
|
-
logger.trace("Closing connection
|
244
|
+
logger.trace("Closing connection...")
|
249
245
|
conn.close()
|
250
246
|
|
247
|
+
def _new_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
248
|
+
"""
|
249
|
+
Perform a new token request using the provided payload.
|
250
|
+
|
251
|
+
:param payload: Payload for the token request.
|
252
|
+
:return: Parsed JSON response or None on failure.
|
253
|
+
"""
|
254
|
+
headers = self._get_common_headers(recursive_call=True)
|
255
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
256
|
+
del headers["Authorization"]
|
257
|
+
|
258
|
+
body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
259
|
+
status, data = self._send_request("POST", self.token_endpoint, headers, body)
|
260
|
+
|
261
|
+
if status == 200:
|
262
|
+
logger.trace("Token refresh successful.")
|
263
|
+
return json.loads(data)
|
264
|
+
|
265
|
+
if status:
|
266
|
+
logger.error("Token refresh failed: %s", status)
|
267
|
+
logger.debug("Response body: %s", data)
|
268
|
+
|
251
269
|
return None
|
252
270
|
|
253
271
|
def _http_resp_header_logic(self, response: http.client.HTTPResponse) -> None:
|
@@ -320,6 +338,23 @@ class SFAuth:
|
|
320
338
|
logger.error("Failed to obtain access token.")
|
321
339
|
return None
|
322
340
|
|
341
|
+
def _get_common_headers(self, recursive_call: bool = False) -> Dict[str, str]:
|
342
|
+
"""
|
343
|
+
Generate common headers for API requests.
|
344
|
+
|
345
|
+
:return: A dictionary of common headers.
|
346
|
+
"""
|
347
|
+
if not recursive_call:
|
348
|
+
self._refresh_token_if_needed()
|
349
|
+
|
350
|
+
return {
|
351
|
+
"Authorization": f"Bearer {self.access_token}",
|
352
|
+
"User-Agent": self.user_agent,
|
353
|
+
"Sforce-Call-Options": f"client={self.sforce_client}",
|
354
|
+
"Accept": "application/json",
|
355
|
+
"Content-Type": "application/json",
|
356
|
+
}
|
357
|
+
|
323
358
|
def _is_token_expired(self) -> bool:
|
324
359
|
"""
|
325
360
|
Check if the access token has expired.
|
@@ -369,52 +404,15 @@ class SFAuth:
|
|
369
404
|
:param resource_id: ID of the static resource to read.
|
370
405
|
:return: Static resource content or None on failure.
|
371
406
|
"""
|
372
|
-
self._refresh_token_if_needed()
|
373
|
-
|
374
|
-
if not self.access_token:
|
375
|
-
logger.error("No access token available for limits.")
|
376
|
-
return None
|
377
|
-
|
378
407
|
endpoint = f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}/Body"
|
379
|
-
headers =
|
380
|
-
|
381
|
-
"User-Agent": self.user_agent,
|
382
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
383
|
-
"Accept": "application/json",
|
384
|
-
}
|
408
|
+
headers = self._get_common_headers()
|
409
|
+
status, data = self._send_request("GET", endpoint, headers)
|
385
410
|
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
try:
|
390
|
-
logger.trace("Request endpoint: %s", endpoint)
|
391
|
-
logger.trace("Request headers: %s", headers)
|
392
|
-
conn.request("GET", endpoint, headers=headers)
|
393
|
-
response = conn.getresponse()
|
394
|
-
data = response.read().decode("utf-8")
|
395
|
-
self._http_resp_header_logic(response)
|
396
|
-
|
397
|
-
if response.status == 200:
|
398
|
-
logger.debug("Get Static Resource Body API request successful.")
|
399
|
-
logger.trace("Response body: %s", data)
|
400
|
-
return data
|
401
|
-
|
402
|
-
logger.error(
|
403
|
-
"Get Static Resource Body API request failed: %s %s",
|
404
|
-
response.status,
|
405
|
-
response.reason,
|
406
|
-
)
|
407
|
-
logger.debug("Response body: %s", data)
|
408
|
-
|
409
|
-
except Exception as err:
|
410
|
-
logger.exception(
|
411
|
-
"Error during Get Static Resource Body API request: %s", err
|
412
|
-
)
|
413
|
-
|
414
|
-
finally:
|
415
|
-
logger.trace("Closing connection...")
|
416
|
-
conn.close()
|
411
|
+
if status == 200:
|
412
|
+
logger.debug("Static resource fetched successfully.")
|
413
|
+
return data
|
417
414
|
|
415
|
+
logger.error("Failed to fetch static resource: %s", status)
|
418
416
|
return None
|
419
417
|
|
420
418
|
def update_static_resource_name(
|
@@ -461,111 +459,48 @@ class SFAuth:
|
|
461
459
|
:param data: Content to update the static resource with.
|
462
460
|
:return: Parsed JSON response or None on failure.
|
463
461
|
"""
|
464
|
-
|
465
|
-
|
466
|
-
if not self.access_token:
|
467
|
-
logger.error("No access token available for limits.")
|
468
|
-
return None
|
469
|
-
|
470
|
-
payload = {"Body": base64.b64encode(data.encode("utf-8"))}
|
462
|
+
payload = {"Body": base64.b64encode(data.encode("utf-8")).decode("utf-8")}
|
471
463
|
|
472
464
|
endpoint = (
|
473
465
|
f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
|
474
466
|
)
|
475
|
-
headers =
|
476
|
-
"Authorization": f"Bearer {self.access_token}",
|
477
|
-
"User-Agent": self.user_agent,
|
478
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
479
|
-
"Content-Type": "application/json",
|
480
|
-
"Accept": "application/json",
|
481
|
-
}
|
482
|
-
|
483
|
-
parsed_url = urlparse(self.instance_url)
|
484
|
-
conn = self._create_connection(parsed_url.netloc)
|
485
|
-
|
486
|
-
try:
|
487
|
-
logger.trace("Request endpoint: %s", endpoint)
|
488
|
-
logger.trace("Request headers: %s", headers)
|
489
|
-
logger.trace("Request payload: %s", payload)
|
490
|
-
conn.request(
|
491
|
-
"PATCH",
|
492
|
-
endpoint,
|
493
|
-
headers=headers,
|
494
|
-
body=json.dumps(payload, default=lambda x: x.decode("utf-8")),
|
495
|
-
)
|
496
|
-
response = conn.getresponse()
|
497
|
-
data = response.read().decode("utf-8")
|
498
|
-
self._http_resp_header_logic(response)
|
499
|
-
|
500
|
-
if response.status == 200:
|
501
|
-
logger.debug("Patch Static Resource request successful.")
|
502
|
-
logger.trace("Response body: %s", data)
|
503
|
-
return json.loads(data)
|
467
|
+
headers = self._get_common_headers()
|
504
468
|
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
)
|
510
|
-
|
469
|
+
status_code, response_data = self._send_request(
|
470
|
+
method="PATCH",
|
471
|
+
endpoint=endpoint,
|
472
|
+
headers=headers,
|
473
|
+
body=json.dumps(payload),
|
474
|
+
)
|
511
475
|
|
512
|
-
|
513
|
-
logger.
|
476
|
+
if status_code == 200:
|
477
|
+
logger.debug("Patch Static Resource request successful.")
|
478
|
+
return json.loads(response_data)
|
514
479
|
|
515
|
-
|
516
|
-
|
517
|
-
|
480
|
+
logger.error(
|
481
|
+
"Patch Static Resource API request failed: %s",
|
482
|
+
status_code,
|
483
|
+
)
|
484
|
+
logger.debug("Response body: %s", response_data)
|
518
485
|
|
519
486
|
return None
|
520
487
|
|
521
488
|
def limits(self) -> Optional[Dict[str, Any]]:
|
522
489
|
"""
|
523
|
-
|
490
|
+
Fetch the current limits for the Salesforce instance.
|
524
491
|
|
525
492
|
:return: Parsed JSON response or None on failure.
|
526
493
|
"""
|
527
|
-
self._refresh_token_if_needed()
|
528
|
-
|
529
|
-
if not self.access_token:
|
530
|
-
logger.error("No access token available for limits.")
|
531
|
-
return None
|
532
|
-
|
533
494
|
endpoint = f"/services/data/{self.api_version}/limits"
|
534
|
-
headers =
|
535
|
-
"Authorization": f"Bearer {self.access_token}",
|
536
|
-
"User-Agent": self.user_agent,
|
537
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
538
|
-
"Accept": "application/json",
|
539
|
-
}
|
540
|
-
|
541
|
-
parsed_url = urlparse(self.instance_url)
|
542
|
-
conn = self._create_connection(parsed_url.netloc)
|
543
|
-
|
544
|
-
try:
|
545
|
-
logger.trace("Request endpoint: %s", endpoint)
|
546
|
-
logger.trace("Request headers: %s", headers)
|
547
|
-
conn.request("GET", endpoint, headers=headers)
|
548
|
-
response = conn.getresponse()
|
549
|
-
data = response.read().decode("utf-8")
|
550
|
-
self._http_resp_header_logic(response)
|
495
|
+
headers = self._get_common_headers()
|
551
496
|
|
552
|
-
|
553
|
-
logger.debug("Limits API request successful.")
|
554
|
-
logger.trace("Response body: %s", data)
|
555
|
-
return json.loads(data)
|
556
|
-
|
557
|
-
logger.error(
|
558
|
-
"Limits API request failed: %s %s", response.status, response.reason
|
559
|
-
)
|
560
|
-
logger.debug("Response body: %s", data)
|
561
|
-
|
562
|
-
except Exception as err:
|
563
|
-
logger.exception("Error during limits request: %s", err)
|
497
|
+
status, data = self._send_request("GET", endpoint, headers)
|
564
498
|
|
565
|
-
|
566
|
-
logger.debug("
|
567
|
-
|
499
|
+
if status == 200:
|
500
|
+
logger.debug("Limits fetched successfully.")
|
501
|
+
return json.loads(data)
|
568
502
|
|
503
|
+
logger.error("Failed to fetch limits: %s", status)
|
569
504
|
return None
|
570
505
|
|
571
506
|
def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
|
@@ -576,39 +511,27 @@ class SFAuth:
|
|
576
511
|
:param tooling: If True, use the Tooling API endpoint.
|
577
512
|
:return: Parsed JSON response or None on failure.
|
578
513
|
"""
|
579
|
-
self._refresh_token_if_needed()
|
580
|
-
|
581
|
-
if not self.access_token:
|
582
|
-
logger.error("No access token available for query.")
|
583
|
-
return None
|
584
|
-
|
585
514
|
endpoint = f"/services/data/{self.api_version}/"
|
586
515
|
endpoint += "tooling/query" if tooling else "query"
|
587
516
|
query_string = f"?q={quote(query)}"
|
588
|
-
|
589
517
|
endpoint += query_string
|
518
|
+
headers = self._get_common_headers()
|
590
519
|
|
591
|
-
|
592
|
-
"Authorization": f"Bearer {self.access_token}",
|
593
|
-
"User-Agent": self.user_agent,
|
594
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
595
|
-
"Accept": "application/json",
|
596
|
-
}
|
597
|
-
|
598
|
-
parsed_url = urlparse(self.instance_url)
|
599
|
-
conn = self._create_connection(parsed_url.netloc)
|
520
|
+
paginated_results = {"totalSize": 0, "done": False, "records": []}
|
600
521
|
|
601
522
|
try:
|
602
|
-
paginated_results = {"totalSize": 0, "done": False, "records": []}
|
603
523
|
while True:
|
604
524
|
logger.trace("Request endpoint: %s", endpoint)
|
605
525
|
logger.trace("Request headers: %s", headers)
|
606
|
-
|
607
|
-
|
608
|
-
data =
|
609
|
-
|
526
|
+
headers = self._get_common_headers() # handle refresh token
|
527
|
+
|
528
|
+
status_code, data = self._send_request(
|
529
|
+
method="GET",
|
530
|
+
endpoint=endpoint,
|
531
|
+
headers=headers,
|
532
|
+
)
|
610
533
|
|
611
|
-
if
|
534
|
+
if status_code == 200:
|
612
535
|
current_results = json.loads(data)
|
613
536
|
paginated_results["records"].extend(current_results["records"])
|
614
537
|
query_done = current_results.get("done")
|
@@ -633,9 +556,8 @@ class SFAuth:
|
|
633
556
|
else:
|
634
557
|
logger.debug("Query failed: %r", query)
|
635
558
|
logger.error(
|
636
|
-
"Query failed with HTTP status %s
|
637
|
-
|
638
|
-
response.reason,
|
559
|
+
"Query failed with HTTP status %s",
|
560
|
+
status_code,
|
639
561
|
)
|
640
562
|
logger.debug("Query response: %s", data)
|
641
563
|
break
|
@@ -645,10 +567,6 @@ class SFAuth:
|
|
645
567
|
except Exception as err:
|
646
568
|
logger.exception("Exception during query: %s", err)
|
647
569
|
|
648
|
-
finally:
|
649
|
-
logger.trace("Closing connection...")
|
650
|
-
conn.close()
|
651
|
-
|
652
570
|
return None
|
653
571
|
|
654
572
|
def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
|
@@ -678,33 +596,22 @@ class SFAuth:
|
|
678
596
|
)
|
679
597
|
return None
|
680
598
|
|
681
|
-
self._refresh_token_if_needed()
|
682
|
-
|
683
|
-
if not self.access_token:
|
684
|
-
logger.error("No access token available for key prefixes.")
|
685
|
-
return None
|
686
|
-
|
687
599
|
endpoint = f"/services/data/{self.api_version}/sobjects/"
|
688
|
-
headers =
|
689
|
-
"Authorization": f"Bearer {self.access_token}",
|
690
|
-
"User-Agent": self.user_agent,
|
691
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
692
|
-
"Accept": "application/json",
|
693
|
-
}
|
600
|
+
headers = self._get_common_headers()
|
694
601
|
|
695
|
-
parsed_url = urlparse(self.instance_url)
|
696
|
-
conn = self._create_connection(parsed_url.netloc)
|
697
602
|
prefixes = {}
|
698
603
|
|
699
604
|
try:
|
700
605
|
logger.trace("Request endpoint: %s", endpoint)
|
701
606
|
logger.trace("Request headers: %s", headers)
|
702
|
-
conn.request("GET", endpoint, headers=headers)
|
703
|
-
response = conn.getresponse()
|
704
|
-
data = response.read().decode("utf-8")
|
705
|
-
self._http_resp_header_logic(response)
|
706
607
|
|
707
|
-
|
608
|
+
status_code, data = self._send_request(
|
609
|
+
method="GET",
|
610
|
+
endpoint=endpoint,
|
611
|
+
headers=headers,
|
612
|
+
)
|
613
|
+
|
614
|
+
if status_code == 200:
|
708
615
|
logger.debug("Key prefixes API request successful.")
|
709
616
|
logger.trace("Response body: %s", data)
|
710
617
|
for sobject in json.loads(data)["sobjects"]:
|
@@ -722,23 +629,18 @@ class SFAuth:
|
|
722
629
|
return prefixes
|
723
630
|
|
724
631
|
logger.error(
|
725
|
-
"Key prefixes API request failed: %s
|
726
|
-
|
727
|
-
response.reason,
|
632
|
+
"Key prefixes API request failed: %s",
|
633
|
+
status_code,
|
728
634
|
)
|
729
635
|
logger.debug("Response body: %s", data)
|
730
636
|
|
731
637
|
except Exception as err:
|
732
638
|
logger.exception("Exception during key prefixes API request: %s", err)
|
733
639
|
|
734
|
-
finally:
|
735
|
-
logger.trace("Closing connection...")
|
736
|
-
conn.close()
|
737
|
-
|
738
640
|
return None
|
739
641
|
|
740
642
|
def cquery(
|
741
|
-
self, query_dict: dict[str, str], max_workers: int =
|
643
|
+
self, query_dict: dict[str, str], batch_size: int = 25, max_workers: int = None
|
742
644
|
) -> Optional[Dict[str, Any]]:
|
743
645
|
"""
|
744
646
|
Execute multiple SOQL queries using the Composite Batch API with threading to reduce network overhead.
|
@@ -747,28 +649,17 @@ class SFAuth:
|
|
747
649
|
Each query (subrequest) is counted as a unique API request against Salesforce governance limits.
|
748
650
|
|
749
651
|
:param query_dict: A dictionary of SOQL queries with keys as logical names and values as SOQL queries.
|
750
|
-
:param
|
652
|
+
:param batch_size: The number of queries to include in each batch (default is 25).
|
653
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
751
654
|
:return: Dict mapping the original keys to their corresponding batch response or None on failure.
|
752
655
|
"""
|
753
656
|
if not query_dict:
|
754
657
|
logger.warning("No queries to execute.")
|
755
658
|
return None
|
756
659
|
|
757
|
-
self._refresh_token_if_needed()
|
758
|
-
|
759
|
-
if not self.access_token:
|
760
|
-
logger.error("No access token available for query.")
|
761
|
-
return None
|
762
|
-
|
763
660
|
def _execute_batch(queries_batch):
|
764
661
|
endpoint = f"/services/data/{self.api_version}/composite/batch"
|
765
|
-
headers =
|
766
|
-
"Authorization": f"Bearer {self.access_token}",
|
767
|
-
"User-Agent": self.user_agent,
|
768
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
769
|
-
"Accept": "application/json",
|
770
|
-
"Content-Type": "application/json",
|
771
|
-
}
|
662
|
+
headers = self._get_common_headers()
|
772
663
|
|
773
664
|
payload = {
|
774
665
|
"haltOnError": False,
|
@@ -781,75 +672,65 @@ class SFAuth:
|
|
781
672
|
],
|
782
673
|
}
|
783
674
|
|
784
|
-
|
785
|
-
|
786
|
-
|
675
|
+
status_code, data = self._send_request(
|
676
|
+
method="POST",
|
677
|
+
endpoint=endpoint,
|
678
|
+
headers=headers,
|
679
|
+
body=json.dumps(payload),
|
680
|
+
)
|
787
681
|
|
788
|
-
|
789
|
-
|
790
|
-
logger.
|
791
|
-
logger.trace("
|
792
|
-
|
793
|
-
|
794
|
-
|
795
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
801
|
-
|
802
|
-
|
803
|
-
|
804
|
-
|
805
|
-
|
806
|
-
records.extend(result["result"]["records"])
|
807
|
-
# Handle pagination
|
808
|
-
while not result["result"].get("done", True):
|
809
|
-
next_url = result["result"].get("nextRecordsUrl")
|
810
|
-
if next_url:
|
811
|
-
conn.request("GET", next_url, headers=headers)
|
812
|
-
response = conn.getresponse()
|
813
|
-
data = response.read().decode("utf-8")
|
814
|
-
self._http_resp_header_logic(response)
|
815
|
-
if response.status == 200:
|
816
|
-
next_results = json.loads(data)
|
817
|
-
records.extend(next_results.get("records", []))
|
818
|
-
result["result"]["done"] = next_results.get("done")
|
819
|
-
else:
|
820
|
-
logger.error(
|
821
|
-
"Failed to fetch next records: %s",
|
822
|
-
response.reason,
|
823
|
-
)
|
824
|
-
break
|
825
|
-
else:
|
826
|
-
result["result"]["done"] = True
|
827
|
-
paginated_results = result["result"]
|
828
|
-
paginated_results["records"] = records
|
829
|
-
if "nextRecordsUrl" in paginated_results:
|
830
|
-
del paginated_results["nextRecordsUrl"]
|
831
|
-
batch_results[keys[i]] = paginated_results
|
832
|
-
if result.get("statusCode") != 200:
|
833
|
-
logger.error("Query failed for key %s: %s", keys[i], result)
|
834
|
-
logger.error(
|
835
|
-
"Query failed with HTTP status %s (%s)",
|
836
|
-
result.get("statusCode"),
|
837
|
-
result.get("statusMessage"),
|
682
|
+
batch_results = {}
|
683
|
+
if status_code == 200:
|
684
|
+
logger.debug("Composite query successful.")
|
685
|
+
logger.trace("Composite query full response: %s", data)
|
686
|
+
results = json.loads(data).get("results", [])
|
687
|
+
for i, result in enumerate(results):
|
688
|
+
records = []
|
689
|
+
if "result" in result and "records" in result["result"]:
|
690
|
+
records.extend(result["result"]["records"])
|
691
|
+
# Handle pagination
|
692
|
+
while not result["result"].get("done", True):
|
693
|
+
headers = self._get_common_headers() # handles token refresh
|
694
|
+
next_url = result["result"].get("nextRecordsUrl")
|
695
|
+
if next_url:
|
696
|
+
status_code, next_data = self._send_request(
|
697
|
+
method="GET",
|
698
|
+
endpoint=next_url,
|
699
|
+
headers=headers,
|
838
700
|
)
|
839
|
-
|
840
|
-
|
841
|
-
|
842
|
-
|
843
|
-
|
844
|
-
|
845
|
-
|
846
|
-
|
847
|
-
|
848
|
-
|
849
|
-
|
850
|
-
|
851
|
-
|
852
|
-
|
701
|
+
if status_code == 200:
|
702
|
+
next_results = json.loads(next_data)
|
703
|
+
records.extend(next_results.get("records", []))
|
704
|
+
result["result"]["done"] = next_results.get("done")
|
705
|
+
else:
|
706
|
+
logger.error(
|
707
|
+
"Failed to fetch next records: %s",
|
708
|
+
next_data,
|
709
|
+
)
|
710
|
+
break
|
711
|
+
else:
|
712
|
+
result["result"]["done"] = True
|
713
|
+
paginated_results = result["result"]
|
714
|
+
paginated_results["records"] = records
|
715
|
+
if "nextRecordsUrl" in paginated_results:
|
716
|
+
del paginated_results["nextRecordsUrl"]
|
717
|
+
batch_results[keys[i]] = paginated_results
|
718
|
+
if result.get("statusCode") != 200:
|
719
|
+
logger.error("Query failed for key %s: %s", keys[i], result)
|
720
|
+
logger.error(
|
721
|
+
"Query failed with HTTP status %s (%s)",
|
722
|
+
result.get("statusCode"),
|
723
|
+
result.get("statusMessage"),
|
724
|
+
)
|
725
|
+
logger.trace("Query response: %s", result)
|
726
|
+
else:
|
727
|
+
logger.error(
|
728
|
+
"Composite query failed with HTTP status %s (%s)",
|
729
|
+
status_code,
|
730
|
+
data,
|
731
|
+
)
|
732
|
+
batch_results[keys[i]] = data
|
733
|
+
logger.trace("Composite query response: %s", data)
|
853
734
|
|
854
735
|
return batch_results
|
855
736
|
|
@@ -858,8 +739,9 @@ class SFAuth:
|
|
858
739
|
|
859
740
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
860
741
|
futures = []
|
861
|
-
|
862
|
-
|
742
|
+
BATCH_SIZE = 25
|
743
|
+
for i in range(0, len(keys), BATCH_SIZE):
|
744
|
+
batch_keys = keys[i : i + BATCH_SIZE]
|
863
745
|
batch_queries = [query_dict[key] for key in batch_keys]
|
864
746
|
futures.append(executor.submit(_execute_batch, batch_queries))
|
865
747
|
|
@@ -869,229 +751,50 @@ class SFAuth:
|
|
869
751
|
logger.trace("Composite query results: %s", results_dict)
|
870
752
|
return results_dict
|
871
753
|
|
872
|
-
def
|
873
|
-
|
874
|
-
|
875
|
-
f"Reconnecting after failure, backoff {wait_time}s (attempt {attempt})"
|
876
|
-
)
|
877
|
-
time.sleep(wait_time)
|
878
|
-
|
879
|
-
def _subscribe_topic(
|
880
|
-
self,
|
881
|
-
topic: str,
|
882
|
-
queue_timeout: int = 90,
|
883
|
-
max_runtime: Optional[int] = None,
|
884
|
-
):
|
754
|
+
def cdelete(
|
755
|
+
self, ids: Iterable[str], batch_size: int = 200, max_workers: int = None
|
756
|
+
) -> Optional[Dict[str, Any]]:
|
885
757
|
"""
|
886
|
-
|
758
|
+
Execute the Collections Delete API to delete multiple records using multithreading.
|
887
759
|
|
888
|
-
:param
|
889
|
-
:param
|
890
|
-
:param
|
760
|
+
:param ids: A list of record IDs to delete.
|
761
|
+
:param batch_size: The number of records to delete in each batch (default is 200).
|
762
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
763
|
+
:return: Combined JSON response from all batches or None on complete failure.
|
891
764
|
"""
|
892
|
-
|
893
|
-
|
894
|
-
ExperimentalWarning,
|
895
|
-
stacklevel=2,
|
896
|
-
)
|
897
|
-
|
898
|
-
self._refresh_token_if_needed()
|
899
|
-
self._msg_count: int = 0
|
900
|
-
|
901
|
-
if not self.access_token:
|
902
|
-
logger.error("No access token available for event stream.")
|
903
|
-
return
|
904
|
-
|
905
|
-
start_time = time.time()
|
906
|
-
message_queue = Queue()
|
907
|
-
headers = {
|
908
|
-
"Authorization": f"Bearer {self.access_token}",
|
909
|
-
"Content-Type": "application/json",
|
910
|
-
"Accept": "application/json",
|
911
|
-
"User-Agent": self.user_agent,
|
912
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
913
|
-
}
|
914
|
-
|
915
|
-
parsed_url = urlparse(self.instance_url)
|
916
|
-
conn = self._create_connection(parsed_url.netloc)
|
917
|
-
_API_VERSION = str(self.api_version).removeprefix("v")
|
918
|
-
client_id = str()
|
919
|
-
|
920
|
-
try:
|
921
|
-
logger.trace("Starting handshake with Salesforce CometD server.")
|
922
|
-
handshake_payload = json.dumps(
|
923
|
-
{
|
924
|
-
"id": str(self._msg_count + 1),
|
925
|
-
"version": "1.0",
|
926
|
-
"minimumVersion": "1.0",
|
927
|
-
"channel": "/meta/handshake",
|
928
|
-
"supportedConnectionTypes": ["long-polling"],
|
929
|
-
"advice": {"timeout": 60000, "interval": 0},
|
930
|
-
}
|
931
|
-
)
|
932
|
-
conn.request(
|
933
|
-
"POST",
|
934
|
-
f"/cometd/{_API_VERSION}/meta/handshake",
|
935
|
-
headers=headers,
|
936
|
-
body=handshake_payload,
|
937
|
-
)
|
938
|
-
response = conn.getresponse()
|
939
|
-
self._http_resp_header_logic(response)
|
765
|
+
ids = list(ids)
|
766
|
+
chunks = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)]
|
940
767
|
|
941
|
-
|
942
|
-
|
943
|
-
|
944
|
-
_bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
|
945
|
-
";"
|
946
|
-
)[0]
|
947
|
-
headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
|
948
|
-
break
|
949
|
-
|
950
|
-
data = json.loads(response.read().decode("utf-8"))
|
951
|
-
if not data or not data[0].get("successful"):
|
952
|
-
logger.error("Handshake failed: %s", data)
|
953
|
-
return
|
768
|
+
def delete_chunk(chunk: List[str]) -> Optional[Dict[str, Any]]:
|
769
|
+
endpoint = f"/services/data/{self.api_version}/composite/sobjects?ids={','.join(chunk)}&allOrNone=false"
|
770
|
+
headers = self._get_common_headers()
|
954
771
|
|
955
|
-
|
956
|
-
|
957
|
-
|
958
|
-
logger.trace(f"Subscribing to topic: {topic}")
|
959
|
-
subscribe_message = {
|
960
|
-
"channel": "/meta/subscribe",
|
961
|
-
"clientId": client_id,
|
962
|
-
"subscription": topic,
|
963
|
-
"id": str(self._msg_count + 1),
|
964
|
-
}
|
965
|
-
conn.request(
|
966
|
-
"POST",
|
967
|
-
f"/cometd/{_API_VERSION}/meta/subscribe",
|
772
|
+
status_code, resp_data = self._send_request(
|
773
|
+
method="DELETE",
|
774
|
+
endpoint=endpoint,
|
968
775
|
headers=headers,
|
969
|
-
body=json.dumps(subscribe_message),
|
970
776
|
)
|
971
|
-
response = conn.getresponse()
|
972
|
-
self._http_resp_header_logic(response)
|
973
|
-
|
974
|
-
sub_response = json.loads(response.read().decode("utf-8"))
|
975
|
-
if not sub_response or not sub_response[0].get("successful"):
|
976
|
-
logger.error("Subscription failed: %s", sub_response)
|
977
|
-
return
|
978
|
-
|
979
|
-
logger.info(f"Successfully subscribed to topic: {topic}")
|
980
|
-
logger.trace("Entering event polling loop.")
|
981
|
-
|
982
|
-
try:
|
983
|
-
while True:
|
984
|
-
if max_runtime and (time.time() - start_time > max_runtime):
|
985
|
-
logger.info(
|
986
|
-
f"Disconnecting after max_runtime={max_runtime} seconds"
|
987
|
-
)
|
988
|
-
break
|
989
|
-
|
990
|
-
logger.trace("Sending connection message.")
|
991
|
-
connect_payload = json.dumps(
|
992
|
-
[
|
993
|
-
{
|
994
|
-
"channel": "/meta/connect",
|
995
|
-
"clientId": client_id,
|
996
|
-
"connectionType": "long-polling",
|
997
|
-
"id": str(self._msg_count + 1),
|
998
|
-
}
|
999
|
-
]
|
1000
|
-
)
|
1001
|
-
|
1002
|
-
max_retries = 5
|
1003
|
-
attempt = 0
|
1004
777
|
|
1005
|
-
|
1006
|
-
|
1007
|
-
|
1008
|
-
|
1009
|
-
|
1010
|
-
|
1011
|
-
|
1012
|
-
)
|
1013
|
-
response = conn.getresponse()
|
1014
|
-
self._http_resp_header_logic(response)
|
1015
|
-
self._msg_count += 1
|
1016
|
-
|
1017
|
-
events = json.loads(response.read().decode("utf-8"))
|
1018
|
-
for event in events:
|
1019
|
-
if event.get("channel") == topic and "data" in event:
|
1020
|
-
logger.trace(
|
1021
|
-
f"Event received for topic {topic}, data: {event['data']}"
|
1022
|
-
)
|
1023
|
-
message_queue.put(event)
|
1024
|
-
break
|
1025
|
-
except (
|
1026
|
-
http.client.RemoteDisconnected,
|
1027
|
-
ConnectionResetError,
|
1028
|
-
TimeoutError,
|
1029
|
-
http.client.BadStatusLine,
|
1030
|
-
http.client.CannotSendRequest,
|
1031
|
-
ConnectionAbortedError,
|
1032
|
-
ConnectionRefusedError,
|
1033
|
-
ConnectionError,
|
1034
|
-
) as e:
|
1035
|
-
logger.warning(
|
1036
|
-
f"Connection error (attempt {attempt + 1}): {e}"
|
1037
|
-
)
|
1038
|
-
conn.close()
|
1039
|
-
conn = self._create_connection(parsed_url.netloc)
|
1040
|
-
self._reconnect_with_backoff(attempt)
|
1041
|
-
attempt += 1
|
1042
|
-
except Exception as e:
|
1043
|
-
logger.exception(
|
1044
|
-
f"Connection error (attempt {attempt + 1}): {e}"
|
1045
|
-
)
|
1046
|
-
break
|
1047
|
-
else:
|
1048
|
-
logger.error("Max retries reached. Exiting event stream.")
|
1049
|
-
break
|
1050
|
-
|
1051
|
-
while True:
|
1052
|
-
try:
|
1053
|
-
msg = message_queue.get(timeout=queue_timeout, block=True)
|
1054
|
-
yield msg
|
1055
|
-
except Empty:
|
1056
|
-
logger.debug(
|
1057
|
-
f"Heartbeat: no message in last {queue_timeout} seconds"
|
1058
|
-
)
|
1059
|
-
break
|
1060
|
-
except KeyboardInterrupt:
|
1061
|
-
logger.info("Received keyboard interrupt, disconnecting...")
|
1062
|
-
|
1063
|
-
except Exception as e:
|
1064
|
-
logger.exception(f"Polling error: {e}")
|
778
|
+
if status_code == 200:
|
779
|
+
logger.debug("Collections delete API response without errors.")
|
780
|
+
return json.loads(resp_data)
|
781
|
+
else:
|
782
|
+
logger.error("Collections delete API request failed: %s", status_code)
|
783
|
+
logger.debug("Response body: %s", resp_data)
|
784
|
+
return None
|
1065
785
|
|
1066
|
-
|
1067
|
-
|
1068
|
-
|
1069
|
-
|
1070
|
-
|
1071
|
-
|
1072
|
-
|
1073
|
-
|
1074
|
-
|
1075
|
-
|
1076
|
-
|
1077
|
-
|
1078
|
-
|
1079
|
-
|
1080
|
-
|
1081
|
-
conn.request(
|
1082
|
-
"POST",
|
1083
|
-
f"/cometd/{_API_VERSION}/meta/disconnect",
|
1084
|
-
headers=headers,
|
1085
|
-
body=disconnect_payload,
|
1086
|
-
)
|
1087
|
-
response = conn.getresponse()
|
1088
|
-
self._http_resp_header_logic(response)
|
1089
|
-
_ = response.read()
|
1090
|
-
logger.trace("Disconnected successfully.")
|
1091
|
-
except Exception as e:
|
1092
|
-
logger.warning(f"Exception during disconnect: {e}")
|
1093
|
-
if conn:
|
1094
|
-
logger.trace("Closing connection.")
|
1095
|
-
conn.close()
|
1096
|
-
|
1097
|
-
logger.trace("Leaving event polling loop.")
|
786
|
+
results = []
|
787
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
788
|
+
futures = [executor.submit(delete_chunk, chunk) for chunk in chunks]
|
789
|
+
for future in as_completed(futures):
|
790
|
+
result = future.result()
|
791
|
+
if result:
|
792
|
+
results.append(result)
|
793
|
+
|
794
|
+
combined_response = [
|
795
|
+
item
|
796
|
+
for result in results
|
797
|
+
for item in (result if isinstance(result, list) else [result])
|
798
|
+
if isinstance(result, (dict, list))
|
799
|
+
]
|
800
|
+
return combined_response or None
|
@@ -0,0 +1,297 @@
|
|
1
|
+
import http.client
|
2
|
+
import json
|
3
|
+
import logging
|
4
|
+
import time
|
5
|
+
from typing import Any, Optional
|
6
|
+
import warnings
|
7
|
+
from queue import Empty, Queue
|
8
|
+
|
9
|
+
TRACE = 5
|
10
|
+
logging.addLevelName(TRACE, "TRACE")
|
11
|
+
|
12
|
+
class ExperimentalWarning(Warning):
|
13
|
+
pass
|
14
|
+
|
15
|
+
|
16
|
+
def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
|
17
|
+
"""Custom TRACE level logging function with redaction."""
|
18
|
+
|
19
|
+
def _redact_sensitive(data: Any) -> Any:
|
20
|
+
"""Redacts sensitive keys from a dictionary or query string."""
|
21
|
+
REDACT_VALUE = "*" * 8
|
22
|
+
REDACT_KEYS = [
|
23
|
+
"access_token",
|
24
|
+
"authorization",
|
25
|
+
"set-cookie",
|
26
|
+
"cookie",
|
27
|
+
"refresh_token",
|
28
|
+
]
|
29
|
+
if isinstance(data, dict):
|
30
|
+
return {
|
31
|
+
k: (REDACT_VALUE if k.lower() in REDACT_KEYS else v)
|
32
|
+
for k, v in data.items()
|
33
|
+
}
|
34
|
+
elif isinstance(data, (list, tuple)):
|
35
|
+
return type(data)(
|
36
|
+
(
|
37
|
+
(item[0], REDACT_VALUE)
|
38
|
+
if isinstance(item, tuple) and item[0].lower() in REDACT_KEYS
|
39
|
+
else item
|
40
|
+
for item in data
|
41
|
+
)
|
42
|
+
)
|
43
|
+
elif isinstance(data, str):
|
44
|
+
parts = data.split("&")
|
45
|
+
for i, part in enumerate(parts):
|
46
|
+
if "=" in part:
|
47
|
+
key, value = part.split("=", 1)
|
48
|
+
if key.lower() in REDACT_KEYS:
|
49
|
+
parts[i] = f"{key}={REDACT_VALUE}"
|
50
|
+
return "&".join(parts)
|
51
|
+
return data
|
52
|
+
|
53
|
+
redacted_args = args
|
54
|
+
if args:
|
55
|
+
first = args[0]
|
56
|
+
if isinstance(first, str):
|
57
|
+
try:
|
58
|
+
loaded = json.loads(first)
|
59
|
+
first = loaded
|
60
|
+
except (json.JSONDecodeError, TypeError):
|
61
|
+
pass
|
62
|
+
redacted_first = _redact_sensitive(first)
|
63
|
+
redacted_args = (redacted_first,) + args[1:]
|
64
|
+
|
65
|
+
if self.isEnabledFor(TRACE):
|
66
|
+
self._log(TRACE, message, redacted_args, **kwargs)
|
67
|
+
|
68
|
+
|
69
|
+
logging.Logger.trace = trace
|
70
|
+
logger = logging.getLogger("sfq")
|
71
|
+
|
72
|
+
def _reconnect_with_backoff(self, attempt: int) -> None:
|
73
|
+
wait_time = min(2**attempt, 60)
|
74
|
+
logger.warning(
|
75
|
+
f"Reconnecting after failure, backoff {wait_time}s (attempt {attempt})"
|
76
|
+
)
|
77
|
+
time.sleep(wait_time)
|
78
|
+
|
79
|
+
def _subscribe_topic(
|
80
|
+
self,
|
81
|
+
topic: str,
|
82
|
+
queue_timeout: int = 90,
|
83
|
+
max_runtime: Optional[int] = None,
|
84
|
+
):
|
85
|
+
"""
|
86
|
+
Yields events from a subscribed Salesforce CometD topic.
|
87
|
+
|
88
|
+
:param topic: Topic to subscribe to, e.g. '/event/MyEvent__e'
|
89
|
+
:param queue_timeout: Seconds to wait for a message before logging heartbeat
|
90
|
+
:param max_runtime: Max total time to listen in seconds (None = unlimited)
|
91
|
+
"""
|
92
|
+
warnings.warn(
|
93
|
+
"The _subscribe_topic method is experimental and subject to change in future versions.",
|
94
|
+
ExperimentalWarning,
|
95
|
+
stacklevel=2,
|
96
|
+
)
|
97
|
+
|
98
|
+
self._refresh_token_if_needed()
|
99
|
+
self._msg_count: int = 0
|
100
|
+
|
101
|
+
if not self.access_token:
|
102
|
+
logger.error("No access token available for event stream.")
|
103
|
+
return
|
104
|
+
|
105
|
+
start_time = time.time()
|
106
|
+
message_queue = Queue()
|
107
|
+
headers = {
|
108
|
+
"Authorization": f"Bearer {self.access_token}",
|
109
|
+
"Content-Type": "application/json",
|
110
|
+
"Accept": "application/json",
|
111
|
+
"User-Agent": self.user_agent,
|
112
|
+
"Sforce-Call-Options": f"client={self.sforce_client}",
|
113
|
+
}
|
114
|
+
|
115
|
+
parsed_url = urlparse(self.instance_url)
|
116
|
+
conn = self._create_connection(parsed_url.netloc)
|
117
|
+
_API_VERSION = str(self.api_version).removeprefix("v")
|
118
|
+
client_id = str()
|
119
|
+
|
120
|
+
try:
|
121
|
+
logger.trace("Starting handshake with Salesforce CometD server.")
|
122
|
+
handshake_payload = json.dumps(
|
123
|
+
{
|
124
|
+
"id": str(self._msg_count + 1),
|
125
|
+
"version": "1.0",
|
126
|
+
"minimumVersion": "1.0",
|
127
|
+
"channel": "/meta/handshake",
|
128
|
+
"supportedConnectionTypes": ["long-polling"],
|
129
|
+
"advice": {"timeout": 60000, "interval": 0},
|
130
|
+
}
|
131
|
+
)
|
132
|
+
conn.request(
|
133
|
+
"POST",
|
134
|
+
f"/cometd/{_API_VERSION}/meta/handshake",
|
135
|
+
headers=headers,
|
136
|
+
body=handshake_payload,
|
137
|
+
)
|
138
|
+
response = conn.getresponse()
|
139
|
+
self._http_resp_header_logic(response)
|
140
|
+
|
141
|
+
logger.trace("Received handshake response.")
|
142
|
+
for name, value in response.getheaders():
|
143
|
+
if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
|
144
|
+
_bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
|
145
|
+
";"
|
146
|
+
)[0]
|
147
|
+
headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
|
148
|
+
break
|
149
|
+
|
150
|
+
data = json.loads(response.read().decode("utf-8"))
|
151
|
+
if not data or not data[0].get("successful"):
|
152
|
+
logger.error("Handshake failed: %s", data)
|
153
|
+
return
|
154
|
+
|
155
|
+
client_id = data[0]["clientId"]
|
156
|
+
logger.trace(f"Handshake successful, client ID: {client_id}")
|
157
|
+
|
158
|
+
logger.trace(f"Subscribing to topic: {topic}")
|
159
|
+
subscribe_message = {
|
160
|
+
"channel": "/meta/subscribe",
|
161
|
+
"clientId": client_id,
|
162
|
+
"subscription": topic,
|
163
|
+
"id": str(self._msg_count + 1),
|
164
|
+
}
|
165
|
+
conn.request(
|
166
|
+
"POST",
|
167
|
+
f"/cometd/{_API_VERSION}/meta/subscribe",
|
168
|
+
headers=headers,
|
169
|
+
body=json.dumps(subscribe_message),
|
170
|
+
)
|
171
|
+
response = conn.getresponse()
|
172
|
+
self._http_resp_header_logic(response)
|
173
|
+
|
174
|
+
sub_response = json.loads(response.read().decode("utf-8"))
|
175
|
+
if not sub_response or not sub_response[0].get("successful"):
|
176
|
+
logger.error("Subscription failed: %s", sub_response)
|
177
|
+
return
|
178
|
+
|
179
|
+
logger.info(f"Successfully subscribed to topic: {topic}")
|
180
|
+
logger.trace("Entering event polling loop.")
|
181
|
+
|
182
|
+
try:
|
183
|
+
while True:
|
184
|
+
if max_runtime and (time.time() - start_time > max_runtime):
|
185
|
+
logger.info(
|
186
|
+
f"Disconnecting after max_runtime={max_runtime} seconds"
|
187
|
+
)
|
188
|
+
break
|
189
|
+
|
190
|
+
logger.trace("Sending connection message.")
|
191
|
+
connect_payload = json.dumps(
|
192
|
+
[
|
193
|
+
{
|
194
|
+
"channel": "/meta/connect",
|
195
|
+
"clientId": client_id,
|
196
|
+
"connectionType": "long-polling",
|
197
|
+
"id": str(self._msg_count + 1),
|
198
|
+
}
|
199
|
+
]
|
200
|
+
)
|
201
|
+
|
202
|
+
max_retries = 5
|
203
|
+
attempt = 0
|
204
|
+
|
205
|
+
while attempt < max_retries:
|
206
|
+
try:
|
207
|
+
conn.request(
|
208
|
+
"POST",
|
209
|
+
f"/cometd/{_API_VERSION}/meta/connect",
|
210
|
+
headers=headers,
|
211
|
+
body=connect_payload,
|
212
|
+
)
|
213
|
+
response = conn.getresponse()
|
214
|
+
self._http_resp_header_logic(response)
|
215
|
+
self._msg_count += 1
|
216
|
+
|
217
|
+
events = json.loads(response.read().decode("utf-8"))
|
218
|
+
for event in events:
|
219
|
+
if event.get("channel") == topic and "data" in event:
|
220
|
+
logger.trace(
|
221
|
+
f"Event received for topic {topic}, data: {event['data']}"
|
222
|
+
)
|
223
|
+
message_queue.put(event)
|
224
|
+
break
|
225
|
+
except (
|
226
|
+
http.client.RemoteDisconnected,
|
227
|
+
ConnectionResetError,
|
228
|
+
TimeoutError,
|
229
|
+
http.client.BadStatusLine,
|
230
|
+
http.client.CannotSendRequest,
|
231
|
+
ConnectionAbortedError,
|
232
|
+
ConnectionRefusedError,
|
233
|
+
ConnectionError,
|
234
|
+
) as e:
|
235
|
+
logger.warning(
|
236
|
+
f"Connection error (attempt {attempt + 1}): {e}"
|
237
|
+
)
|
238
|
+
conn.close()
|
239
|
+
conn = self._create_connection(parsed_url.netloc)
|
240
|
+
self._reconnect_with_backoff(attempt)
|
241
|
+
attempt += 1
|
242
|
+
except Exception as e:
|
243
|
+
logger.exception(
|
244
|
+
f"Connection error (attempt {attempt + 1}): {e}"
|
245
|
+
)
|
246
|
+
break
|
247
|
+
else:
|
248
|
+
logger.error("Max retries reached. Exiting event stream.")
|
249
|
+
break
|
250
|
+
|
251
|
+
while True:
|
252
|
+
try:
|
253
|
+
msg = message_queue.get(timeout=queue_timeout, block=True)
|
254
|
+
yield msg
|
255
|
+
except Empty:
|
256
|
+
logger.debug(
|
257
|
+
f"Heartbeat: no message in last {queue_timeout} seconds"
|
258
|
+
)
|
259
|
+
break
|
260
|
+
except KeyboardInterrupt:
|
261
|
+
logger.info("Received keyboard interrupt, disconnecting...")
|
262
|
+
|
263
|
+
except Exception as e:
|
264
|
+
logger.exception(f"Polling error: {e}")
|
265
|
+
|
266
|
+
finally:
|
267
|
+
if client_id:
|
268
|
+
try:
|
269
|
+
logger.trace(
|
270
|
+
f"Disconnecting from server with client ID: {client_id}"
|
271
|
+
)
|
272
|
+
disconnect_payload = json.dumps(
|
273
|
+
[
|
274
|
+
{
|
275
|
+
"channel": "/meta/disconnect",
|
276
|
+
"clientId": client_id,
|
277
|
+
"id": str(self._msg_count + 1),
|
278
|
+
}
|
279
|
+
]
|
280
|
+
)
|
281
|
+
conn.request(
|
282
|
+
"POST",
|
283
|
+
f"/cometd/{_API_VERSION}/meta/disconnect",
|
284
|
+
headers=headers,
|
285
|
+
body=disconnect_payload,
|
286
|
+
)
|
287
|
+
response = conn.getresponse()
|
288
|
+
self._http_resp_header_logic(response)
|
289
|
+
_ = response.read()
|
290
|
+
logger.trace("Disconnected successfully.")
|
291
|
+
except Exception as e:
|
292
|
+
logger.warning(f"Exception during disconnect: {e}")
|
293
|
+
if conn:
|
294
|
+
logger.trace("Closing connection.")
|
295
|
+
conn.close()
|
296
|
+
|
297
|
+
logger.trace("Leaving event polling loop.")
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|