sfq 0.0.10__py3-none-any.whl → 0.0.11__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
CHANGED
@@ -4,12 +4,16 @@ import json
|
|
4
4
|
import logging
|
5
5
|
import os
|
6
6
|
import time
|
7
|
+
import warnings
|
8
|
+
from queue import Empty, Queue
|
7
9
|
from typing import Any, Dict, Optional
|
8
10
|
from urllib.parse import quote, urlparse
|
9
11
|
|
10
12
|
TRACE = 5
|
11
13
|
logging.addLevelName(TRACE, "TRACE")
|
12
14
|
|
15
|
+
class ExperimentalWarning(Warning):
|
16
|
+
pass
|
13
17
|
|
14
18
|
def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
|
15
19
|
"""Custom TRACE level logging function with redaction."""
|
@@ -17,25 +21,33 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
|
|
17
21
|
def _redact_sensitive(data: Any) -> Any:
|
18
22
|
"""Redacts sensitive keys from a dictionary or query string."""
|
19
23
|
REDACT_VALUE = "*" * 8
|
24
|
+
REDACT_KEYS = [
|
25
|
+
"access_token",
|
26
|
+
"authorization",
|
27
|
+
"set-cookie",
|
28
|
+
"cookie",
|
29
|
+
"refresh_token",
|
30
|
+
]
|
20
31
|
if isinstance(data, dict):
|
21
32
|
return {
|
22
|
-
k: (
|
23
|
-
REDACT_VALUE
|
24
|
-
if k.lower() in ["access_token", "authorization", "refresh_token"]
|
25
|
-
else v
|
26
|
-
)
|
33
|
+
k: (REDACT_VALUE if k.lower() in REDACT_KEYS else v)
|
27
34
|
for k, v in data.items()
|
28
35
|
}
|
36
|
+
elif isinstance(data, (list, tuple)):
|
37
|
+
return type(data)(
|
38
|
+
(
|
39
|
+
(item[0], REDACT_VALUE)
|
40
|
+
if isinstance(item, tuple) and item[0].lower() in REDACT_KEYS
|
41
|
+
else item
|
42
|
+
for item in data
|
43
|
+
)
|
44
|
+
)
|
29
45
|
elif isinstance(data, str):
|
30
46
|
parts = data.split("&")
|
31
47
|
for i, part in enumerate(parts):
|
32
48
|
if "=" in part:
|
33
49
|
key, value = part.split("=", 1)
|
34
|
-
if key.lower() in
|
35
|
-
"access_token",
|
36
|
-
"authorization",
|
37
|
-
"refresh_token",
|
38
|
-
]:
|
50
|
+
if key.lower() in REDACT_KEYS:
|
39
51
|
parts[i] = f"{key}={REDACT_VALUE}"
|
40
52
|
return "&".join(parts)
|
41
53
|
return data
|
@@ -71,7 +83,7 @@ class SFAuth:
|
|
71
83
|
access_token: Optional[str] = None,
|
72
84
|
token_expiration_time: Optional[float] = None,
|
73
85
|
token_lifetime: int = 15 * 60,
|
74
|
-
user_agent: str = "sfq/0.0.
|
86
|
+
user_agent: str = "sfq/0.0.11",
|
75
87
|
proxy: str = "auto",
|
76
88
|
) -> None:
|
77
89
|
"""
|
@@ -85,7 +97,7 @@ class SFAuth:
|
|
85
97
|
:param access_token: The access token for the current session (default is None).
|
86
98
|
:param token_expiration_time: The expiration time of the access token (default is None).
|
87
99
|
:param token_lifetime: The lifetime of the access token in seconds (default is 15 minutes).
|
88
|
-
:param user_agent: Custom User-Agent string (default is "sfq/0.0.
|
100
|
+
:param user_agent: Custom User-Agent string (default is "sfq/0.0.11").
|
89
101
|
:param proxy: The proxy configuration, "auto" to use environment (default is "auto").
|
90
102
|
"""
|
91
103
|
self.instance_url = instance_url
|
@@ -179,6 +191,7 @@ class SFAuth:
|
|
179
191
|
logger.exception("Error during token request: %s", err)
|
180
192
|
|
181
193
|
finally:
|
194
|
+
logger.trace("Closing connection.")
|
182
195
|
conn.close()
|
183
196
|
|
184
197
|
return None
|
@@ -346,6 +359,7 @@ class SFAuth:
|
|
346
359
|
)
|
347
360
|
|
348
361
|
finally:
|
362
|
+
logger.trace("Closing connection...")
|
349
363
|
conn.close()
|
350
364
|
|
351
365
|
return None
|
@@ -444,6 +458,7 @@ class SFAuth:
|
|
444
458
|
logger.exception("Error during patch request: %s", err)
|
445
459
|
|
446
460
|
finally:
|
461
|
+
logger.trace("Closing connection.")
|
447
462
|
conn.close()
|
448
463
|
|
449
464
|
return None
|
@@ -492,6 +507,7 @@ class SFAuth:
|
|
492
507
|
logger.exception("Error during limits request: %s", err)
|
493
508
|
|
494
509
|
finally:
|
510
|
+
logger.debug("Closing connection...")
|
495
511
|
conn.close()
|
496
512
|
|
497
513
|
return None
|
@@ -573,6 +589,7 @@ class SFAuth:
|
|
573
589
|
logger.exception("Exception during query: %s", err)
|
574
590
|
|
575
591
|
finally:
|
592
|
+
logger.trace("Closing connection...")
|
576
593
|
conn.close()
|
577
594
|
|
578
595
|
return None
|
@@ -585,3 +602,230 @@ class SFAuth:
|
|
585
602
|
:return: Parsed JSON response or None on failure.
|
586
603
|
"""
|
587
604
|
return self.query(query, tooling=True)
|
605
|
+
|
606
|
+
def _reconnect_with_backoff(self, attempt: int) -> None:
|
607
|
+
wait_time = min(2**attempt, 60)
|
608
|
+
logger.warning(
|
609
|
+
f"Reconnecting after failure, backoff {wait_time}s (attempt {attempt})"
|
610
|
+
)
|
611
|
+
time.sleep(wait_time)
|
612
|
+
self._refresh_token_if_needed()
|
613
|
+
|
614
|
+
def _subscribe_topic(
|
615
|
+
self,
|
616
|
+
topic: str,
|
617
|
+
queue_timeout: int = 90,
|
618
|
+
max_runtime: Optional[int] = None,
|
619
|
+
):
|
620
|
+
"""
|
621
|
+
Yields events from a subscribed Salesforce CometD topic.
|
622
|
+
|
623
|
+
:param topic: Topic to subscribe to, e.g. '/event/MyEvent__e'
|
624
|
+
:param queue_timeout: Seconds to wait for a message before logging heartbeat
|
625
|
+
:param max_runtime: Max total time to listen in seconds (None = unlimited)
|
626
|
+
"""
|
627
|
+
warnings.warn(
|
628
|
+
"The _subscribe_topic method is experimental and subject to change in future versions.",
|
629
|
+
ExperimentalWarning,
|
630
|
+
stacklevel=2,
|
631
|
+
)
|
632
|
+
|
633
|
+
self._refresh_token_if_needed()
|
634
|
+
self._msg_count: int = 0
|
635
|
+
|
636
|
+
if not self.access_token:
|
637
|
+
logger.error("No access token available for event stream.")
|
638
|
+
return
|
639
|
+
|
640
|
+
start_time = time.time()
|
641
|
+
message_queue = Queue()
|
642
|
+
headers = {
|
643
|
+
"Authorization": f"Bearer {self.access_token}",
|
644
|
+
"Content-Type": "application/json",
|
645
|
+
"Accept": "application/json",
|
646
|
+
"User-Agent": self.user_agent,
|
647
|
+
}
|
648
|
+
|
649
|
+
parsed_url = urlparse(self.instance_url)
|
650
|
+
conn = self._create_connection(parsed_url.netloc)
|
651
|
+
_API_VERSION = str(self.api_version).removeprefix("v")
|
652
|
+
client_id = str()
|
653
|
+
|
654
|
+
try:
|
655
|
+
logger.trace("Starting handshake with Salesforce CometD server.")
|
656
|
+
handshake_payload = json.dumps(
|
657
|
+
{
|
658
|
+
"id": str(self._msg_count + 1),
|
659
|
+
"version": "1.0",
|
660
|
+
"minimumVersion": "1.0",
|
661
|
+
"channel": "/meta/handshake",
|
662
|
+
"supportedConnectionTypes": ["long-polling"],
|
663
|
+
"advice": {"timeout": 60000, "interval": 0},
|
664
|
+
}
|
665
|
+
)
|
666
|
+
conn.request(
|
667
|
+
"POST",
|
668
|
+
f"/cometd/{_API_VERSION}/meta/handshake",
|
669
|
+
headers=headers,
|
670
|
+
body=handshake_payload,
|
671
|
+
)
|
672
|
+
response = conn.getresponse()
|
673
|
+
self._http_resp_header_logic(response)
|
674
|
+
|
675
|
+
logger.trace("Received handshake response.")
|
676
|
+
for name, value in response.getheaders():
|
677
|
+
if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
|
678
|
+
_bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
|
679
|
+
";"
|
680
|
+
)[0]
|
681
|
+
headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
|
682
|
+
break
|
683
|
+
|
684
|
+
data = json.loads(response.read().decode("utf-8"))
|
685
|
+
if not data or not data[0].get("successful"):
|
686
|
+
logger.error("Handshake failed: %s", data)
|
687
|
+
return
|
688
|
+
|
689
|
+
client_id = data[0]["clientId"]
|
690
|
+
logger.trace(f"Handshake successful, client ID: {client_id}")
|
691
|
+
|
692
|
+
logger.trace(f"Subscribing to topic: {topic}")
|
693
|
+
subscribe_message = {
|
694
|
+
"channel": "/meta/subscribe",
|
695
|
+
"clientId": client_id,
|
696
|
+
"subscription": topic,
|
697
|
+
"id": str(self._msg_count + 1),
|
698
|
+
}
|
699
|
+
conn.request(
|
700
|
+
"POST",
|
701
|
+
f"/cometd/{_API_VERSION}/meta/subscribe",
|
702
|
+
headers=headers,
|
703
|
+
body=json.dumps(subscribe_message),
|
704
|
+
)
|
705
|
+
response = conn.getresponse()
|
706
|
+
self._http_resp_header_logic(response)
|
707
|
+
|
708
|
+
sub_response = json.loads(response.read().decode("utf-8"))
|
709
|
+
if not sub_response or not sub_response[0].get("successful"):
|
710
|
+
logger.error("Subscription failed: %s", sub_response)
|
711
|
+
return
|
712
|
+
|
713
|
+
logger.info(f"Successfully subscribed to topic: {topic}")
|
714
|
+
logger.trace("Entering event polling loop.")
|
715
|
+
|
716
|
+
try:
|
717
|
+
while True:
|
718
|
+
if max_runtime and (time.time() - start_time > max_runtime):
|
719
|
+
logger.info(
|
720
|
+
f"Disconnecting after max_runtime={max_runtime} seconds"
|
721
|
+
)
|
722
|
+
break
|
723
|
+
|
724
|
+
logger.trace("Sending connection message.")
|
725
|
+
connect_payload = json.dumps(
|
726
|
+
[
|
727
|
+
{
|
728
|
+
"channel": "/meta/connect",
|
729
|
+
"clientId": client_id,
|
730
|
+
"connectionType": "long-polling",
|
731
|
+
"id": str(self._msg_count + 1),
|
732
|
+
}
|
733
|
+
]
|
734
|
+
)
|
735
|
+
|
736
|
+
max_retries = 5
|
737
|
+
attempt = 0
|
738
|
+
|
739
|
+
while attempt < max_retries:
|
740
|
+
try:
|
741
|
+
conn.request(
|
742
|
+
"POST",
|
743
|
+
f"/cometd/{_API_VERSION}/meta/connect",
|
744
|
+
headers=headers,
|
745
|
+
body=connect_payload,
|
746
|
+
)
|
747
|
+
response = conn.getresponse()
|
748
|
+
self._http_resp_header_logic(response)
|
749
|
+
self._msg_count += 1
|
750
|
+
|
751
|
+
events = json.loads(response.read().decode("utf-8"))
|
752
|
+
for event in events:
|
753
|
+
if event.get("channel") == topic and "data" in event:
|
754
|
+
logger.trace(
|
755
|
+
f"Event received for topic {topic}, data: {event['data']}"
|
756
|
+
)
|
757
|
+
message_queue.put(event)
|
758
|
+
break
|
759
|
+
except (
|
760
|
+
http.client.RemoteDisconnected,
|
761
|
+
ConnectionResetError,
|
762
|
+
TimeoutError,
|
763
|
+
http.client.BadStatusLine,
|
764
|
+
http.client.CannotSendRequest,
|
765
|
+
ConnectionAbortedError,
|
766
|
+
ConnectionRefusedError,
|
767
|
+
ConnectionError,
|
768
|
+
) as e:
|
769
|
+
logger.warning(
|
770
|
+
f"Connection error (attempt {attempt + 1}): {e}"
|
771
|
+
)
|
772
|
+
conn.close()
|
773
|
+
conn = self._create_connection(parsed_url.netloc)
|
774
|
+
self._reconnect_with_backoff(attempt)
|
775
|
+
attempt += 1
|
776
|
+
except Exception as e:
|
777
|
+
logger.exception(
|
778
|
+
f"Connection error (attempt {attempt + 1}): {e}"
|
779
|
+
)
|
780
|
+
break
|
781
|
+
else:
|
782
|
+
logger.error("Max retries reached. Exiting event stream.")
|
783
|
+
break
|
784
|
+
|
785
|
+
while True:
|
786
|
+
try:
|
787
|
+
msg = message_queue.get(timeout=queue_timeout, block=True)
|
788
|
+
yield msg
|
789
|
+
except Empty:
|
790
|
+
logger.debug(
|
791
|
+
f"Heartbeat: no message in last {queue_timeout} seconds"
|
792
|
+
)
|
793
|
+
break
|
794
|
+
except KeyboardInterrupt:
|
795
|
+
logger.info("Received keyboard interrupt, disconnecting...")
|
796
|
+
|
797
|
+
except Exception as e:
|
798
|
+
logger.exception(f"Polling error: {e}")
|
799
|
+
|
800
|
+
finally:
|
801
|
+
if client_id:
|
802
|
+
try:
|
803
|
+
logger.trace(
|
804
|
+
f"Disconnecting from server with client ID: {client_id}"
|
805
|
+
)
|
806
|
+
disconnect_payload = json.dumps(
|
807
|
+
[
|
808
|
+
{
|
809
|
+
"channel": "/meta/disconnect",
|
810
|
+
"clientId": client_id,
|
811
|
+
"id": str(self._msg_count + 1),
|
812
|
+
}
|
813
|
+
]
|
814
|
+
)
|
815
|
+
conn.request(
|
816
|
+
"POST",
|
817
|
+
f"/cometd/{_API_VERSION}/meta/disconnect",
|
818
|
+
headers=headers,
|
819
|
+
body=disconnect_payload,
|
820
|
+
)
|
821
|
+
response = conn.getresponse()
|
822
|
+
self._http_resp_header_logic(response)
|
823
|
+
_ = response.read()
|
824
|
+
logger.trace("Disconnected successfully.")
|
825
|
+
except Exception as e:
|
826
|
+
logger.warning(f"Exception during disconnect: {e}")
|
827
|
+
if conn:
|
828
|
+
logger.trace("Closing connection.")
|
829
|
+
conn.close()
|
830
|
+
|
831
|
+
logger.trace("Leaving event polling loop.")
|
@@ -0,0 +1,5 @@
|
|
1
|
+
sfq/__init__.py,sha256=QkMVcIOaQO7XP29zK9l7uQCTx0NcqIDBNj8V08Llu24,31595
|
2
|
+
sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
sfq-0.0.11.dist-info/METADATA,sha256=F1sqI833stEXIk9Z7jtG6q0F8dPuEGMDW8xgFgGxa4M,5067
|
4
|
+
sfq-0.0.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
5
|
+
sfq-0.0.11.dist-info/RECORD,,
|
sfq-0.0.10.dist-info/RECORD
DELETED
@@ -1,5 +0,0 @@
|
|
1
|
-
sfq/__init__.py,sha256=oUFxQHH31dAkke5OX7IxVaiXSmxbNdHXM6NasVAvB8I,21809
|
2
|
-
sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
sfq-0.0.10.dist-info/METADATA,sha256=9Gm_aVMOPJkR0Kj1ZHLMprgzAq-PAng2OALYku1eNWA,5067
|
4
|
-
sfq-0.0.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
5
|
-
sfq-0.0.10.dist-info/RECORD,,
|
File without changes
|