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.10",
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.10").
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.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.10
3
+ Version: 0.0.11
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=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,,
@@ -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