sfq 0.0.14__py3-none-any.whl → 0.0.15__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.
- sfq/__init__.py +191 -529
- sfq/_cometd.py +297 -0
- {sfq-0.0.14.dist-info → sfq-0.0.15.dist-info}/METADATA +1 -1
- sfq-0.0.15.dist-info/RECORD +6 -0
- sfq-0.0.14.dist-info/RECORD +0 -5
- {sfq-0.0.14.dist-info → sfq-0.0.15.dist-info}/WHEEL +0 -0
sfq/__init__.py
CHANGED
@@ -7,8 +7,7 @@ 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, Literal, Optional, List, Tuple
|
12
11
|
from urllib.parse import quote, urlparse
|
13
12
|
|
14
13
|
TRACE = 5
|
@@ -31,6 +30,7 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
|
|
31
30
|
"set-cookie",
|
32
31
|
"cookie",
|
33
32
|
"refresh_token",
|
33
|
+
"client_secret",
|
34
34
|
]
|
35
35
|
if isinstance(data, dict):
|
36
36
|
return {
|
@@ -81,15 +81,15 @@ class SFAuth:
|
|
81
81
|
self,
|
82
82
|
instance_url: str,
|
83
83
|
client_id: str,
|
84
|
-
refresh_token: str,
|
84
|
+
refresh_token: str, # client_secret & refresh_token will swap positions 2025-AUG-1
|
85
85
|
client_secret: str = "_deprecation_warning", # mandatory after 2025-AUG-1
|
86
86
|
api_version: str = "v63.0",
|
87
87
|
token_endpoint: str = "/services/oauth2/token",
|
88
88
|
access_token: Optional[str] = None,
|
89
89
|
token_expiration_time: Optional[float] = None,
|
90
90
|
token_lifetime: int = 15 * 60,
|
91
|
-
user_agent: str = "sfq/0.0.
|
92
|
-
sforce_client: str =
|
91
|
+
user_agent: str = "sfq/0.0.15",
|
92
|
+
sforce_client: str = "_auto",
|
93
93
|
proxy: str = "auto",
|
94
94
|
) -> None:
|
95
95
|
"""
|
@@ -104,7 +104,7 @@ class SFAuth:
|
|
104
104
|
:param access_token: The access token for the current session (default is None).
|
105
105
|
:param token_expiration_time: The expiration time of the access token (default is None).
|
106
106
|
: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.
|
107
|
+
:param user_agent: Custom User-Agent string (default is "sfq/0.0.15").
|
108
108
|
:param sforce_client: Custom Application Identifier (default is user_agent).
|
109
109
|
:param proxy: The proxy configuration, "auto" to use environment (default is "auto").
|
110
110
|
"""
|
@@ -122,7 +122,7 @@ class SFAuth:
|
|
122
122
|
self._auto_configure_proxy(proxy)
|
123
123
|
self._high_api_usage_threshold = 80
|
124
124
|
|
125
|
-
if sforce_client ==
|
125
|
+
if sforce_client == "_auto":
|
126
126
|
self.sforce_client = user_agent
|
127
127
|
|
128
128
|
if self.client_secret == "_deprecation_warning":
|
@@ -138,7 +138,6 @@ class SFAuth:
|
|
138
138
|
)
|
139
139
|
|
140
140
|
def _format_instance_url(self, instance_url) -> str:
|
141
|
-
# check if it begins with https://
|
142
141
|
if instance_url.startswith("https://"):
|
143
142
|
return instance_url
|
144
143
|
if instance_url.startswith("http://"):
|
@@ -205,49 +204,73 @@ class SFAuth:
|
|
205
204
|
logger.trace("Direct connection to %s", netloc)
|
206
205
|
return conn
|
207
206
|
|
208
|
-
def
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
:
|
213
|
-
:
|
207
|
+
def _send_request(
|
208
|
+
self,
|
209
|
+
method: str,
|
210
|
+
endpoint: str,
|
211
|
+
headers: Dict[str, str],
|
212
|
+
body: Optional[str] = None,
|
213
|
+
timeout: Optional[int] = None,
|
214
|
+
) -> Tuple[Optional[int], Optional[str]]:
|
215
|
+
"""
|
216
|
+
Unified request method with built-in logging and error handling.
|
217
|
+
|
218
|
+
:param method: HTTP method to use.
|
219
|
+
:param endpoint: Target API endpoint.
|
220
|
+
:param headers: HTTP headers.
|
221
|
+
:param body: Optional request body.
|
222
|
+
:param timeout: Optional timeout in seconds.
|
223
|
+
:return: Tuple of HTTP status code and response body as a string.
|
214
224
|
"""
|
215
225
|
parsed_url = urlparse(self.instance_url)
|
216
226
|
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
227
|
|
225
228
|
try:
|
226
|
-
logger.trace("Request
|
227
|
-
logger.trace("Request
|
229
|
+
logger.trace("Request method: %s", method)
|
230
|
+
logger.trace("Request endpoint: %s", endpoint)
|
228
231
|
logger.trace("Request headers: %s", headers)
|
229
|
-
|
232
|
+
if body:
|
233
|
+
logger.trace("Request body: %s", body)
|
234
|
+
|
235
|
+
conn.request(method, endpoint, body=body, headers=headers)
|
230
236
|
response = conn.getresponse()
|
231
|
-
data = response.read().decode("utf-8")
|
232
237
|
self._http_resp_header_logic(response)
|
233
238
|
|
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)
|
239
|
+
data = response.read().decode("utf-8")
|
240
|
+
logger.trace("Response status: %s", response.status)
|
241
|
+
logger.trace("Response body: %s", data)
|
242
|
+
return response.status, data
|
243
243
|
|
244
244
|
except Exception as err:
|
245
|
-
logger.exception("
|
245
|
+
logger.exception("HTTP request failed: %s", err)
|
246
|
+
return None, None
|
246
247
|
|
247
248
|
finally:
|
248
|
-
logger.trace("Closing connection
|
249
|
+
logger.trace("Closing connection...")
|
249
250
|
conn.close()
|
250
251
|
|
252
|
+
def _new_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
253
|
+
"""
|
254
|
+
Perform a new token request using the provided payload.
|
255
|
+
|
256
|
+
:param payload: Payload for the token request.
|
257
|
+
:return: Parsed JSON response or None on failure.
|
258
|
+
"""
|
259
|
+
headers = self._get_common_headers()
|
260
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
261
|
+
del headers["Authorization"]
|
262
|
+
|
263
|
+
body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
264
|
+
status, data = self._send_request("POST", self.token_endpoint, headers, body)
|
265
|
+
|
266
|
+
if status == 200:
|
267
|
+
logger.trace("Token refresh successful.")
|
268
|
+
return json.loads(data)
|
269
|
+
|
270
|
+
if status:
|
271
|
+
logger.error("Token refresh failed: %s", status)
|
272
|
+
logger.debug("Response body: %s", data)
|
273
|
+
|
251
274
|
return None
|
252
275
|
|
253
276
|
def _http_resp_header_logic(self, response: http.client.HTTPResponse) -> None:
|
@@ -320,6 +343,26 @@ class SFAuth:
|
|
320
343
|
logger.error("Failed to obtain access token.")
|
321
344
|
return None
|
322
345
|
|
346
|
+
def _get_common_headers(self) -> Dict[str, str]:
|
347
|
+
"""
|
348
|
+
Generate common headers for API requests.
|
349
|
+
|
350
|
+
:return: A dictionary of common headers.
|
351
|
+
"""
|
352
|
+
if not self.access_token and self.token_expiration_time is None:
|
353
|
+
self.token_expiration_time = int(time.time())
|
354
|
+
self._refresh_token_if_needed()
|
355
|
+
|
356
|
+
return {
|
357
|
+
"Authorization": f"Bearer {self.access_token}",
|
358
|
+
"User-Agent": self.user_agent,
|
359
|
+
"Sforce-Call-Options": f"client={self.sforce_client}",
|
360
|
+
"Accept": "application/json",
|
361
|
+
"Content-Type": "application/json",
|
362
|
+
}
|
363
|
+
|
364
|
+
|
365
|
+
|
323
366
|
def _is_token_expired(self) -> bool:
|
324
367
|
"""
|
325
368
|
Check if the access token has expired.
|
@@ -369,52 +412,15 @@ class SFAuth:
|
|
369
412
|
:param resource_id: ID of the static resource to read.
|
370
413
|
:return: Static resource content or None on failure.
|
371
414
|
"""
|
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
415
|
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
|
-
}
|
385
|
-
|
386
|
-
parsed_url = urlparse(self.instance_url)
|
387
|
-
conn = self._create_connection(parsed_url.netloc)
|
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
|
-
)
|
416
|
+
headers = self._get_common_headers()
|
417
|
+
status, data = self._send_request("GET", endpoint, headers)
|
413
418
|
|
414
|
-
|
415
|
-
logger.
|
416
|
-
|
419
|
+
if status == 200:
|
420
|
+
logger.debug("Static resource fetched successfully.")
|
421
|
+
return data
|
417
422
|
|
423
|
+
logger.error("Failed to fetch static resource: %s", status)
|
418
424
|
return None
|
419
425
|
|
420
426
|
def update_static_resource_name(
|
@@ -461,111 +467,48 @@ class SFAuth:
|
|
461
467
|
:param data: Content to update the static resource with.
|
462
468
|
:return: Parsed JSON response or None on failure.
|
463
469
|
"""
|
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"))}
|
470
|
+
payload = {"Body": base64.b64encode(data.encode("utf-8")).decode("utf-8")}
|
471
471
|
|
472
472
|
endpoint = (
|
473
473
|
f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
|
474
474
|
)
|
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)
|
475
|
+
headers = self._get_common_headers()
|
504
476
|
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
)
|
510
|
-
|
477
|
+
status_code, response_data = self._send_request(
|
478
|
+
method="PATCH",
|
479
|
+
endpoint=endpoint,
|
480
|
+
headers=headers,
|
481
|
+
body=json.dumps(payload),
|
482
|
+
)
|
511
483
|
|
512
|
-
|
513
|
-
logger.
|
484
|
+
if status_code == 200:
|
485
|
+
logger.debug("Patch Static Resource request successful.")
|
486
|
+
return json.loads(response_data)
|
514
487
|
|
515
|
-
|
516
|
-
|
517
|
-
|
488
|
+
logger.error(
|
489
|
+
"Patch Static Resource API request failed: %s",
|
490
|
+
status_code,
|
491
|
+
)
|
492
|
+
logger.debug("Response body: %s", response_data)
|
518
493
|
|
519
494
|
return None
|
520
495
|
|
521
496
|
def limits(self) -> Optional[Dict[str, Any]]:
|
522
497
|
"""
|
523
|
-
|
498
|
+
Fetch the current limits for the Salesforce instance.
|
524
499
|
|
525
500
|
:return: Parsed JSON response or None on failure.
|
526
501
|
"""
|
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
502
|
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)
|
551
|
-
|
552
|
-
if response.status == 200:
|
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)
|
503
|
+
headers = self._get_common_headers()
|
561
504
|
|
562
|
-
|
563
|
-
logger.exception("Error during limits request: %s", err)
|
505
|
+
status, data = self._send_request("GET", endpoint, headers)
|
564
506
|
|
565
|
-
|
566
|
-
logger.debug("
|
567
|
-
|
507
|
+
if status == 200:
|
508
|
+
logger.debug("Limits fetched successfully.")
|
509
|
+
return json.loads(data)
|
568
510
|
|
511
|
+
logger.error("Failed to fetch limits: %s", status)
|
569
512
|
return None
|
570
513
|
|
571
514
|
def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
|
@@ -576,39 +519,27 @@ class SFAuth:
|
|
576
519
|
:param tooling: If True, use the Tooling API endpoint.
|
577
520
|
:return: Parsed JSON response or None on failure.
|
578
521
|
"""
|
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
522
|
endpoint = f"/services/data/{self.api_version}/"
|
586
523
|
endpoint += "tooling/query" if tooling else "query"
|
587
524
|
query_string = f"?q={quote(query)}"
|
588
|
-
|
589
525
|
endpoint += query_string
|
526
|
+
headers = self._get_common_headers()
|
590
527
|
|
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)
|
528
|
+
paginated_results = {"totalSize": 0, "done": False, "records": []}
|
600
529
|
|
601
530
|
try:
|
602
|
-
paginated_results = {"totalSize": 0, "done": False, "records": []}
|
603
531
|
while True:
|
604
532
|
logger.trace("Request endpoint: %s", endpoint)
|
605
533
|
logger.trace("Request headers: %s", headers)
|
606
|
-
|
607
|
-
|
608
|
-
data =
|
609
|
-
|
534
|
+
headers = self._get_common_headers() # handle refresh token
|
535
|
+
|
536
|
+
status_code, data = self._send_request(
|
537
|
+
method="GET",
|
538
|
+
endpoint=endpoint,
|
539
|
+
headers=headers,
|
540
|
+
)
|
610
541
|
|
611
|
-
if
|
542
|
+
if status_code == 200:
|
612
543
|
current_results = json.loads(data)
|
613
544
|
paginated_results["records"].extend(current_results["records"])
|
614
545
|
query_done = current_results.get("done")
|
@@ -633,9 +564,8 @@ class SFAuth:
|
|
633
564
|
else:
|
634
565
|
logger.debug("Query failed: %r", query)
|
635
566
|
logger.error(
|
636
|
-
"Query failed with HTTP status %s
|
637
|
-
|
638
|
-
response.reason,
|
567
|
+
"Query failed with HTTP status %s",
|
568
|
+
status_code,
|
639
569
|
)
|
640
570
|
logger.debug("Query response: %s", data)
|
641
571
|
break
|
@@ -645,10 +575,6 @@ class SFAuth:
|
|
645
575
|
except Exception as err:
|
646
576
|
logger.exception("Exception during query: %s", err)
|
647
577
|
|
648
|
-
finally:
|
649
|
-
logger.trace("Closing connection...")
|
650
|
-
conn.close()
|
651
|
-
|
652
578
|
return None
|
653
579
|
|
654
580
|
def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
|
@@ -678,33 +604,22 @@ class SFAuth:
|
|
678
604
|
)
|
679
605
|
return None
|
680
606
|
|
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
607
|
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
|
-
}
|
608
|
+
headers = self._get_common_headers()
|
694
609
|
|
695
|
-
parsed_url = urlparse(self.instance_url)
|
696
|
-
conn = self._create_connection(parsed_url.netloc)
|
697
610
|
prefixes = {}
|
698
611
|
|
699
612
|
try:
|
700
613
|
logger.trace("Request endpoint: %s", endpoint)
|
701
614
|
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
615
|
|
707
|
-
|
616
|
+
status_code, data = self._send_request(
|
617
|
+
method="GET",
|
618
|
+
endpoint=endpoint,
|
619
|
+
headers=headers,
|
620
|
+
)
|
621
|
+
|
622
|
+
if status_code == 200:
|
708
623
|
logger.debug("Key prefixes API request successful.")
|
709
624
|
logger.trace("Response body: %s", data)
|
710
625
|
for sobject in json.loads(data)["sobjects"]:
|
@@ -722,19 +637,14 @@ class SFAuth:
|
|
722
637
|
return prefixes
|
723
638
|
|
724
639
|
logger.error(
|
725
|
-
"Key prefixes API request failed: %s
|
726
|
-
|
727
|
-
response.reason,
|
640
|
+
"Key prefixes API request failed: %s",
|
641
|
+
status_code,
|
728
642
|
)
|
729
643
|
logger.debug("Response body: %s", data)
|
730
644
|
|
731
645
|
except Exception as err:
|
732
646
|
logger.exception("Exception during key prefixes API request: %s", err)
|
733
647
|
|
734
|
-
finally:
|
735
|
-
logger.trace("Closing connection...")
|
736
|
-
conn.close()
|
737
|
-
|
738
648
|
return None
|
739
649
|
|
740
650
|
def cquery(
|
@@ -754,21 +664,9 @@ class SFAuth:
|
|
754
664
|
logger.warning("No queries to execute.")
|
755
665
|
return None
|
756
666
|
|
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
667
|
def _execute_batch(queries_batch):
|
764
668
|
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
|
-
}
|
669
|
+
headers = self._get_common_headers()
|
772
670
|
|
773
671
|
payload = {
|
774
672
|
"haltOnError": False,
|
@@ -781,75 +679,65 @@ class SFAuth:
|
|
781
679
|
],
|
782
680
|
}
|
783
681
|
|
784
|
-
|
785
|
-
|
786
|
-
|
682
|
+
status_code, data = self._send_request(
|
683
|
+
method="POST",
|
684
|
+
endpoint=endpoint,
|
685
|
+
headers=headers,
|
686
|
+
body=json.dumps(payload),
|
687
|
+
)
|
787
688
|
|
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"),
|
689
|
+
batch_results = {}
|
690
|
+
if status_code == 200:
|
691
|
+
logger.debug("Composite query successful.")
|
692
|
+
logger.trace("Composite query full response: %s", data)
|
693
|
+
results = json.loads(data).get("results", [])
|
694
|
+
for i, result in enumerate(results):
|
695
|
+
records = []
|
696
|
+
if "result" in result and "records" in result["result"]:
|
697
|
+
records.extend(result["result"]["records"])
|
698
|
+
# Handle pagination
|
699
|
+
while not result["result"].get("done", True):
|
700
|
+
headers = self._get_common_headers() # handles token refresh
|
701
|
+
next_url = result["result"].get("nextRecordsUrl")
|
702
|
+
if next_url:
|
703
|
+
status_code, next_data = self._send_request(
|
704
|
+
method="GET",
|
705
|
+
endpoint=next_url,
|
706
|
+
headers=headers,
|
838
707
|
)
|
839
|
-
|
840
|
-
|
841
|
-
|
842
|
-
|
843
|
-
|
844
|
-
|
845
|
-
|
846
|
-
|
847
|
-
|
848
|
-
|
849
|
-
|
850
|
-
|
851
|
-
|
852
|
-
|
708
|
+
if status_code == 200:
|
709
|
+
next_results = json.loads(next_data)
|
710
|
+
records.extend(next_results.get("records", []))
|
711
|
+
result["result"]["done"] = next_results.get("done")
|
712
|
+
else:
|
713
|
+
logger.error(
|
714
|
+
"Failed to fetch next records: %s",
|
715
|
+
next_data,
|
716
|
+
)
|
717
|
+
break
|
718
|
+
else:
|
719
|
+
result["result"]["done"] = True
|
720
|
+
paginated_results = result["result"]
|
721
|
+
paginated_results["records"] = records
|
722
|
+
if "nextRecordsUrl" in paginated_results:
|
723
|
+
del paginated_results["nextRecordsUrl"]
|
724
|
+
batch_results[keys[i]] = paginated_results
|
725
|
+
if result.get("statusCode") != 200:
|
726
|
+
logger.error("Query failed for key %s: %s", keys[i], result)
|
727
|
+
logger.error(
|
728
|
+
"Query failed with HTTP status %s (%s)",
|
729
|
+
result.get("statusCode"),
|
730
|
+
result.get("statusMessage"),
|
731
|
+
)
|
732
|
+
logger.trace("Query response: %s", result)
|
733
|
+
else:
|
734
|
+
logger.error(
|
735
|
+
"Composite query failed with HTTP status %s (%s)",
|
736
|
+
status_code,
|
737
|
+
data,
|
738
|
+
)
|
739
|
+
batch_results[keys[i]] = data
|
740
|
+
logger.trace("Composite query response: %s", data)
|
853
741
|
|
854
742
|
return batch_results
|
855
743
|
|
@@ -858,8 +746,9 @@ class SFAuth:
|
|
858
746
|
|
859
747
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
860
748
|
futures = []
|
861
|
-
|
862
|
-
|
749
|
+
BATCH_SIZE = 25
|
750
|
+
for i in range(0, len(keys), BATCH_SIZE):
|
751
|
+
batch_keys = keys[i : i + BATCH_SIZE]
|
863
752
|
batch_queries = [query_dict[key] for key in batch_keys]
|
864
753
|
futures.append(executor.submit(_execute_batch, batch_queries))
|
865
754
|
|
@@ -868,230 +757,3 @@ class SFAuth:
|
|
868
757
|
|
869
758
|
logger.trace("Composite query results: %s", results_dict)
|
870
759
|
return results_dict
|
871
|
-
|
872
|
-
def _reconnect_with_backoff(self, attempt: int) -> None:
|
873
|
-
wait_time = min(2**attempt, 60)
|
874
|
-
logger.warning(
|
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
|
-
):
|
885
|
-
"""
|
886
|
-
Yields events from a subscribed Salesforce CometD topic.
|
887
|
-
|
888
|
-
:param topic: Topic to subscribe to, e.g. '/event/MyEvent__e'
|
889
|
-
:param queue_timeout: Seconds to wait for a message before logging heartbeat
|
890
|
-
:param max_runtime: Max total time to listen in seconds (None = unlimited)
|
891
|
-
"""
|
892
|
-
warnings.warn(
|
893
|
-
"The _subscribe_topic method is experimental and subject to change in future versions.",
|
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)
|
940
|
-
|
941
|
-
logger.trace("Received handshake response.")
|
942
|
-
for name, value in response.getheaders():
|
943
|
-
if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
|
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
|
954
|
-
|
955
|
-
client_id = data[0]["clientId"]
|
956
|
-
logger.trace(f"Handshake successful, client ID: {client_id}")
|
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",
|
968
|
-
headers=headers,
|
969
|
-
body=json.dumps(subscribe_message),
|
970
|
-
)
|
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
|
-
|
1005
|
-
while attempt < max_retries:
|
1006
|
-
try:
|
1007
|
-
conn.request(
|
1008
|
-
"POST",
|
1009
|
-
f"/cometd/{_API_VERSION}/meta/connect",
|
1010
|
-
headers=headers,
|
1011
|
-
body=connect_payload,
|
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}")
|
1065
|
-
|
1066
|
-
finally:
|
1067
|
-
if client_id:
|
1068
|
-
try:
|
1069
|
-
logger.trace(
|
1070
|
-
f"Disconnecting from server with client ID: {client_id}"
|
1071
|
-
)
|
1072
|
-
disconnect_payload = json.dumps(
|
1073
|
-
[
|
1074
|
-
{
|
1075
|
-
"channel": "/meta/disconnect",
|
1076
|
-
"clientId": client_id,
|
1077
|
-
"id": str(self._msg_count + 1),
|
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.")
|
sfq/_cometd.py
ADDED
@@ -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.")
|
@@ -0,0 +1,6 @@
|
|
1
|
+
sfq/__init__.py,sha256=b70qbaov94JC7qWHuJA6X0i6O-H145YS-_vlyPzWig4,29895
|
2
|
+
sfq/_cometd.py,sha256=XimQEubmJwUmbWe85TxH_cuhGvWVuiHHrVr41tguuiI,10508
|
3
|
+
sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
+
sfq-0.0.15.dist-info/METADATA,sha256=ipG9mLqnwZwGp6gUGSbggP_LNl80YcGPpM1_fYlS7Vo,6598
|
5
|
+
sfq-0.0.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
6
|
+
sfq-0.0.15.dist-info/RECORD,,
|
sfq-0.0.14.dist-info/RECORD
DELETED
@@ -1,5 +0,0 @@
|
|
1
|
-
sfq/__init__.py,sha256=sH3sDtjaCCzDaQ0Az17JYSIedAJioxN2PNl9QaxYoYc,43705
|
2
|
-
sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
sfq-0.0.14.dist-info/METADATA,sha256=dgP3Q2fRZjWcJPcJgExfdtZJ_X9TlCSt7FxMe3zicVU,6598
|
4
|
-
sfq-0.0.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
5
|
-
sfq-0.0.14.dist-info/RECORD,,
|
File without changes
|