sfq 0.0.10__py3-none-any.whl → 0.0.12__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,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.10",
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.10").
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.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.10
3
+ Version: 0.0.12
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
@@ -0,0 +1,5 @@
1
+ sfq/__init__.py,sha256=XEKDLgvHBwGfIU0TDQjyxgLnfiFyCtd0a_MW3DnqFxQ,36032
2
+ sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ sfq-0.0.12.dist-info/METADATA,sha256=glq9BPwFsvXAk1yaK30GrbdT3oDdl2Urd3lpdc3JW0s,5067
4
+ sfq-0.0.12.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
+ sfq-0.0.12.dist-info/RECORD,,
@@ -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