sfq 0.0.10__tar.gz → 0.0.12__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.10 → sfq-0.0.12}/PKG-INFO +1 -1
- {sfq-0.0.10 → sfq-0.0.12}/pyproject.toml +1 -1
- {sfq-0.0.10 → sfq-0.0.12}/src/sfq/__init__.py +354 -12
- {sfq-0.0.10 → sfq-0.0.12}/uv.lock +1 -1
- {sfq-0.0.10 → sfq-0.0.12}/.github/workflows/publish.yml +0 -0
- {sfq-0.0.10 → sfq-0.0.12}/.gitignore +0 -0
- {sfq-0.0.10 → sfq-0.0.12}/.python-version +0 -0
- {sfq-0.0.10 → sfq-0.0.12}/README.md +0 -0
- {sfq-0.0.10 → sfq-0.0.12}/src/sfq/py.typed +0 -0
@@ -4,6 +4,10 @@ import json
|
|
4
4
|
import logging
|
5
5
|
import os
|
6
6
|
import time
|
7
|
+
import warnings
|
8
|
+
from collections import OrderedDict
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
10
|
+
from queue import Empty, Queue
|
7
11
|
from typing import Any, Dict, Optional
|
8
12
|
from urllib.parse import quote, urlparse
|
9
13
|
|
@@ -11,31 +15,43 @@ TRACE = 5
|
|
11
15
|
logging.addLevelName(TRACE, "TRACE")
|
12
16
|
|
13
17
|
|
18
|
+
class ExperimentalWarning(Warning):
|
19
|
+
pass
|
20
|
+
|
21
|
+
|
14
22
|
def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
|
15
23
|
"""Custom TRACE level logging function with redaction."""
|
16
24
|
|
17
25
|
def _redact_sensitive(data: Any) -> Any:
|
18
26
|
"""Redacts sensitive keys from a dictionary or query string."""
|
19
27
|
REDACT_VALUE = "*" * 8
|
28
|
+
REDACT_KEYS = [
|
29
|
+
"access_token",
|
30
|
+
"authorization",
|
31
|
+
"set-cookie",
|
32
|
+
"cookie",
|
33
|
+
"refresh_token",
|
34
|
+
]
|
20
35
|
if isinstance(data, dict):
|
21
36
|
return {
|
22
|
-
k: (
|
23
|
-
REDACT_VALUE
|
24
|
-
if k.lower() in ["access_token", "authorization", "refresh_token"]
|
25
|
-
else v
|
26
|
-
)
|
37
|
+
k: (REDACT_VALUE if k.lower() in REDACT_KEYS else v)
|
27
38
|
for k, v in data.items()
|
28
39
|
}
|
40
|
+
elif isinstance(data, (list, tuple)):
|
41
|
+
return type(data)(
|
42
|
+
(
|
43
|
+
(item[0], REDACT_VALUE)
|
44
|
+
if isinstance(item, tuple) and item[0].lower() in REDACT_KEYS
|
45
|
+
else item
|
46
|
+
for item in data
|
47
|
+
)
|
48
|
+
)
|
29
49
|
elif isinstance(data, str):
|
30
50
|
parts = data.split("&")
|
31
51
|
for i, part in enumerate(parts):
|
32
52
|
if "=" in part:
|
33
53
|
key, value = part.split("=", 1)
|
34
|
-
if key.lower() in
|
35
|
-
"access_token",
|
36
|
-
"authorization",
|
37
|
-
"refresh_token",
|
38
|
-
]:
|
54
|
+
if key.lower() in REDACT_KEYS:
|
39
55
|
parts[i] = f"{key}={REDACT_VALUE}"
|
40
56
|
return "&".join(parts)
|
41
57
|
return data
|
@@ -71,7 +87,7 @@ class SFAuth:
|
|
71
87
|
access_token: Optional[str] = None,
|
72
88
|
token_expiration_time: Optional[float] = None,
|
73
89
|
token_lifetime: int = 15 * 60,
|
74
|
-
user_agent: str = "sfq/0.0.
|
90
|
+
user_agent: str = "sfq/0.0.12",
|
75
91
|
proxy: str = "auto",
|
76
92
|
) -> None:
|
77
93
|
"""
|
@@ -85,7 +101,7 @@ class SFAuth:
|
|
85
101
|
:param access_token: The access token for the current session (default is None).
|
86
102
|
:param token_expiration_time: The expiration time of the access token (default is None).
|
87
103
|
: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.
|
104
|
+
:param user_agent: Custom User-Agent string (default is "sfq/0.0.12").
|
89
105
|
:param proxy: The proxy configuration, "auto" to use environment (default is "auto").
|
90
106
|
"""
|
91
107
|
self.instance_url = instance_url
|
@@ -179,6 +195,7 @@ class SFAuth:
|
|
179
195
|
logger.exception("Error during token request: %s", err)
|
180
196
|
|
181
197
|
finally:
|
198
|
+
logger.trace("Closing connection.")
|
182
199
|
conn.close()
|
183
200
|
|
184
201
|
return None
|
@@ -346,6 +363,7 @@ class SFAuth:
|
|
346
363
|
)
|
347
364
|
|
348
365
|
finally:
|
366
|
+
logger.trace("Closing connection...")
|
349
367
|
conn.close()
|
350
368
|
|
351
369
|
return None
|
@@ -444,6 +462,7 @@ class SFAuth:
|
|
444
462
|
logger.exception("Error during patch request: %s", err)
|
445
463
|
|
446
464
|
finally:
|
465
|
+
logger.trace("Closing connection.")
|
447
466
|
conn.close()
|
448
467
|
|
449
468
|
return None
|
@@ -492,6 +511,7 @@ class SFAuth:
|
|
492
511
|
logger.exception("Error during limits request: %s", err)
|
493
512
|
|
494
513
|
finally:
|
514
|
+
logger.debug("Closing connection...")
|
495
515
|
conn.close()
|
496
516
|
|
497
517
|
return None
|
@@ -573,6 +593,7 @@ class SFAuth:
|
|
573
593
|
logger.exception("Exception during query: %s", err)
|
574
594
|
|
575
595
|
finally:
|
596
|
+
logger.trace("Closing connection...")
|
576
597
|
conn.close()
|
577
598
|
|
578
599
|
return None
|
@@ -585,3 +606,324 @@ class SFAuth:
|
|
585
606
|
:return: Parsed JSON response or None on failure.
|
586
607
|
"""
|
587
608
|
return self.query(query, tooling=True)
|
609
|
+
|
610
|
+
def cquery(self, query_dict: dict[str, str], max_workers: int = 10) -> Optional[Dict[str, Any]]:
|
611
|
+
"""
|
612
|
+
Execute multiple SOQL queries using the Composite Batch API with threading to reduce network overhead.
|
613
|
+
The function returns a dictionary mapping the original keys to their corresponding batch response.
|
614
|
+
The function requires a dictionary of SOQL queries with keys as logical names (referenceId) and values as SOQL queries.
|
615
|
+
Each query (subrequest) is counted as a unique API request against Salesforce governance limits.
|
616
|
+
|
617
|
+
:param query_dict: A dictionary of SOQL queries with keys as logical names and values as SOQL queries.
|
618
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is 10).
|
619
|
+
:return: Dict mapping the original keys to their corresponding batch response or None on failure.
|
620
|
+
"""
|
621
|
+
if not query_dict:
|
622
|
+
logger.warning("No queries to execute.")
|
623
|
+
return None
|
624
|
+
|
625
|
+
self._refresh_token_if_needed()
|
626
|
+
|
627
|
+
if not self.access_token:
|
628
|
+
logger.error("No access token available for query.")
|
629
|
+
return None
|
630
|
+
|
631
|
+
def _execute_batch(queries_batch):
|
632
|
+
endpoint = f"/services/data/{self.api_version}/composite/batch"
|
633
|
+
headers = {
|
634
|
+
"Authorization": f"Bearer {self.access_token}",
|
635
|
+
"User-Agent": self.user_agent,
|
636
|
+
"Accept": "application/json",
|
637
|
+
"Content-Type": "application/json",
|
638
|
+
}
|
639
|
+
|
640
|
+
payload = {
|
641
|
+
"haltOnError": False,
|
642
|
+
"batchRequests": [
|
643
|
+
{
|
644
|
+
"method": "GET",
|
645
|
+
"url": f"/services/data/{self.api_version}/query?q={quote(query)}",
|
646
|
+
}
|
647
|
+
for query in queries_batch
|
648
|
+
],
|
649
|
+
}
|
650
|
+
|
651
|
+
parsed_url = urlparse(self.instance_url)
|
652
|
+
conn = self._create_connection(parsed_url.netloc)
|
653
|
+
batch_results = {}
|
654
|
+
|
655
|
+
try:
|
656
|
+
logger.trace("Request endpoint: %s", endpoint)
|
657
|
+
logger.trace("Request headers: %s", headers)
|
658
|
+
logger.trace("Request payload: %s", json.dumps(payload, indent=2))
|
659
|
+
|
660
|
+
conn.request("POST", endpoint, json.dumps(payload), headers=headers)
|
661
|
+
conn.sock.settimeout(60 * 10)
|
662
|
+
response = conn.getresponse()
|
663
|
+
data = response.read().decode("utf-8")
|
664
|
+
self._http_resp_header_logic(response)
|
665
|
+
|
666
|
+
if response.status == 200:
|
667
|
+
logger.debug("Composite query successful.")
|
668
|
+
logger.trace("Composite query full response: %s", data)
|
669
|
+
results = json.loads(data).get("results", [])
|
670
|
+
for i, result in enumerate(results):
|
671
|
+
batch_results[keys[i]] = result
|
672
|
+
if result.get("statusCode") != 200:
|
673
|
+
logger.error("Query failed for key %s: %s", keys[i], result)
|
674
|
+
logger.error(
|
675
|
+
"Query failed with HTTP status %s (%s)",
|
676
|
+
result.get("statusCode"),
|
677
|
+
result.get("statusMessage"),
|
678
|
+
)
|
679
|
+
logger.trace("Query response: %s", result)
|
680
|
+
else:
|
681
|
+
logger.error(
|
682
|
+
"Composite query failed with HTTP status %s (%s)",
|
683
|
+
response.status,
|
684
|
+
response.reason,
|
685
|
+
)
|
686
|
+
batch_results[keys[i]] = data
|
687
|
+
logger.trace("Composite query response: %s", data)
|
688
|
+
except Exception as err:
|
689
|
+
logger.exception("Exception during composite query: %s", err)
|
690
|
+
finally:
|
691
|
+
logger.trace("Closing connection...")
|
692
|
+
conn.close()
|
693
|
+
|
694
|
+
return batch_results
|
695
|
+
|
696
|
+
keys = list(query_dict.keys())
|
697
|
+
results_dict = OrderedDict()
|
698
|
+
|
699
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
700
|
+
futures = []
|
701
|
+
for i in range(0, len(keys), 25):
|
702
|
+
batch_keys = keys[i : i + 25]
|
703
|
+
batch_queries = [query_dict[key] for key in batch_keys]
|
704
|
+
futures.append(executor.submit(_execute_batch, batch_queries))
|
705
|
+
|
706
|
+
for future in as_completed(futures):
|
707
|
+
results_dict.update(future.result())
|
708
|
+
|
709
|
+
logger.trace("Composite query results: %s", results_dict)
|
710
|
+
return results_dict
|
711
|
+
|
712
|
+
def _subscribe_topic(
|
713
|
+
self,
|
714
|
+
topic: str,
|
715
|
+
queue_timeout: int = 90,
|
716
|
+
max_runtime: Optional[int] = None,
|
717
|
+
):
|
718
|
+
"""
|
719
|
+
Yields events from a subscribed Salesforce CometD topic.
|
720
|
+
|
721
|
+
:param topic: Topic to subscribe to, e.g. '/event/MyEvent__e'
|
722
|
+
:param queue_timeout: Seconds to wait for a message before logging heartbeat
|
723
|
+
:param max_runtime: Max total time to listen in seconds (None = unlimited)
|
724
|
+
"""
|
725
|
+
warnings.warn(
|
726
|
+
"The _subscribe_topic method is experimental and subject to change in future versions.",
|
727
|
+
ExperimentalWarning,
|
728
|
+
stacklevel=2,
|
729
|
+
)
|
730
|
+
|
731
|
+
self._refresh_token_if_needed()
|
732
|
+
self._msg_count: int = 0
|
733
|
+
|
734
|
+
if not self.access_token:
|
735
|
+
logger.error("No access token available for event stream.")
|
736
|
+
return
|
737
|
+
|
738
|
+
start_time = time.time()
|
739
|
+
message_queue = Queue()
|
740
|
+
headers = {
|
741
|
+
"Authorization": f"Bearer {self.access_token}",
|
742
|
+
"Content-Type": "application/json",
|
743
|
+
"Accept": "application/json",
|
744
|
+
"User-Agent": self.user_agent,
|
745
|
+
}
|
746
|
+
|
747
|
+
parsed_url = urlparse(self.instance_url)
|
748
|
+
conn = self._create_connection(parsed_url.netloc)
|
749
|
+
_API_VERSION = str(self.api_version).removeprefix("v")
|
750
|
+
client_id = str()
|
751
|
+
|
752
|
+
try:
|
753
|
+
logger.trace("Starting handshake with Salesforce CometD server.")
|
754
|
+
handshake_payload = json.dumps(
|
755
|
+
{
|
756
|
+
"id": str(self._msg_count + 1),
|
757
|
+
"version": "1.0",
|
758
|
+
"minimumVersion": "1.0",
|
759
|
+
"channel": "/meta/handshake",
|
760
|
+
"supportedConnectionTypes": ["long-polling"],
|
761
|
+
"advice": {"timeout": 60000, "interval": 0},
|
762
|
+
}
|
763
|
+
)
|
764
|
+
conn.request(
|
765
|
+
"POST",
|
766
|
+
f"/cometd/{_API_VERSION}/meta/handshake",
|
767
|
+
headers=headers,
|
768
|
+
body=handshake_payload,
|
769
|
+
)
|
770
|
+
response = conn.getresponse()
|
771
|
+
self._http_resp_header_logic(response)
|
772
|
+
|
773
|
+
logger.trace("Received handshake response.")
|
774
|
+
for name, value in response.getheaders():
|
775
|
+
if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
|
776
|
+
_bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
|
777
|
+
";"
|
778
|
+
)[0]
|
779
|
+
headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
|
780
|
+
break
|
781
|
+
|
782
|
+
data = json.loads(response.read().decode("utf-8"))
|
783
|
+
if not data or not data[0].get("successful"):
|
784
|
+
logger.error("Handshake failed: %s", data)
|
785
|
+
return
|
786
|
+
|
787
|
+
client_id = data[0]["clientId"]
|
788
|
+
logger.trace(f"Handshake successful, client ID: {client_id}")
|
789
|
+
|
790
|
+
logger.trace(f"Subscribing to topic: {topic}")
|
791
|
+
subscribe_message = {
|
792
|
+
"channel": "/meta/subscribe",
|
793
|
+
"clientId": client_id,
|
794
|
+
"subscription": topic,
|
795
|
+
"id": str(self._msg_count + 1),
|
796
|
+
}
|
797
|
+
conn.request(
|
798
|
+
"POST",
|
799
|
+
f"/cometd/{_API_VERSION}/meta/subscribe",
|
800
|
+
headers=headers,
|
801
|
+
body=json.dumps(subscribe_message),
|
802
|
+
)
|
803
|
+
response = conn.getresponse()
|
804
|
+
self._http_resp_header_logic(response)
|
805
|
+
|
806
|
+
sub_response = json.loads(response.read().decode("utf-8"))
|
807
|
+
if not sub_response or not sub_response[0].get("successful"):
|
808
|
+
logger.error("Subscription failed: %s", sub_response)
|
809
|
+
return
|
810
|
+
|
811
|
+
logger.info(f"Successfully subscribed to topic: {topic}")
|
812
|
+
logger.trace("Entering event polling loop.")
|
813
|
+
|
814
|
+
try:
|
815
|
+
while True:
|
816
|
+
if max_runtime and (time.time() - start_time > max_runtime):
|
817
|
+
logger.info(
|
818
|
+
f"Disconnecting after max_runtime={max_runtime} seconds"
|
819
|
+
)
|
820
|
+
break
|
821
|
+
|
822
|
+
logger.trace("Sending connection message.")
|
823
|
+
connect_payload = json.dumps(
|
824
|
+
[
|
825
|
+
{
|
826
|
+
"channel": "/meta/connect",
|
827
|
+
"clientId": client_id,
|
828
|
+
"connectionType": "long-polling",
|
829
|
+
"id": str(self._msg_count + 1),
|
830
|
+
}
|
831
|
+
]
|
832
|
+
)
|
833
|
+
|
834
|
+
max_retries = 5
|
835
|
+
attempt = 0
|
836
|
+
|
837
|
+
while attempt < max_retries:
|
838
|
+
try:
|
839
|
+
conn.request(
|
840
|
+
"POST",
|
841
|
+
f"/cometd/{_API_VERSION}/meta/connect",
|
842
|
+
headers=headers,
|
843
|
+
body=connect_payload,
|
844
|
+
)
|
845
|
+
response = conn.getresponse()
|
846
|
+
self._http_resp_header_logic(response)
|
847
|
+
self._msg_count += 1
|
848
|
+
|
849
|
+
events = json.loads(response.read().decode("utf-8"))
|
850
|
+
for event in events:
|
851
|
+
if event.get("channel") == topic and "data" in event:
|
852
|
+
logger.trace(
|
853
|
+
f"Event received for topic {topic}, data: {event['data']}"
|
854
|
+
)
|
855
|
+
message_queue.put(event)
|
856
|
+
break
|
857
|
+
except (
|
858
|
+
http.client.RemoteDisconnected,
|
859
|
+
ConnectionResetError,
|
860
|
+
TimeoutError,
|
861
|
+
http.client.BadStatusLine,
|
862
|
+
http.client.CannotSendRequest,
|
863
|
+
ConnectionAbortedError,
|
864
|
+
ConnectionRefusedError,
|
865
|
+
ConnectionError,
|
866
|
+
) as e:
|
867
|
+
logger.warning(
|
868
|
+
f"Connection error (attempt {attempt + 1}): {e}"
|
869
|
+
)
|
870
|
+
conn.close()
|
871
|
+
conn = self._create_connection(parsed_url.netloc)
|
872
|
+
self._reconnect_with_backoff(attempt)
|
873
|
+
attempt += 1
|
874
|
+
except Exception as e:
|
875
|
+
logger.exception(
|
876
|
+
f"Connection error (attempt {attempt + 1}): {e}"
|
877
|
+
)
|
878
|
+
break
|
879
|
+
else:
|
880
|
+
logger.error("Max retries reached. Exiting event stream.")
|
881
|
+
break
|
882
|
+
|
883
|
+
while True:
|
884
|
+
try:
|
885
|
+
msg = message_queue.get(timeout=queue_timeout, block=True)
|
886
|
+
yield msg
|
887
|
+
except Empty:
|
888
|
+
logger.debug(
|
889
|
+
f"Heartbeat: no message in last {queue_timeout} seconds"
|
890
|
+
)
|
891
|
+
break
|
892
|
+
except KeyboardInterrupt:
|
893
|
+
logger.info("Received keyboard interrupt, disconnecting...")
|
894
|
+
|
895
|
+
except Exception as e:
|
896
|
+
logger.exception(f"Polling error: {e}")
|
897
|
+
|
898
|
+
finally:
|
899
|
+
if client_id:
|
900
|
+
try:
|
901
|
+
logger.trace(
|
902
|
+
f"Disconnecting from server with client ID: {client_id}"
|
903
|
+
)
|
904
|
+
disconnect_payload = json.dumps(
|
905
|
+
[
|
906
|
+
{
|
907
|
+
"channel": "/meta/disconnect",
|
908
|
+
"clientId": client_id,
|
909
|
+
"id": str(self._msg_count + 1),
|
910
|
+
}
|
911
|
+
]
|
912
|
+
)
|
913
|
+
conn.request(
|
914
|
+
"POST",
|
915
|
+
f"/cometd/{_API_VERSION}/meta/disconnect",
|
916
|
+
headers=headers,
|
917
|
+
body=disconnect_payload,
|
918
|
+
)
|
919
|
+
response = conn.getresponse()
|
920
|
+
self._http_resp_header_logic(response)
|
921
|
+
_ = response.read()
|
922
|
+
logger.trace("Disconnected successfully.")
|
923
|
+
except Exception as e:
|
924
|
+
logger.warning(f"Exception during disconnect: {e}")
|
925
|
+
if conn:
|
926
|
+
logger.trace("Closing connection.")
|
927
|
+
conn.close()
|
928
|
+
|
929
|
+
logger.trace("Leaving event polling loop.")
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|