sfq 0.0.14__tar.gz → 0.0.15__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.14
3
+ Version: 0.0.15
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sfq"
3
- version = "0.0.14"
3
+ version = "0.0.15"
4
4
  description = "Python wrapper for the Salesforce's Query API."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "David Moruzzi", email = "sfq.pypi@dmoruzi.com" }]
@@ -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 queue import Empty, Queue
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, # client_secret & refresh_token will swap positions 2025-AUG-1
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.14",
92
- sforce_client: str = '_auto',
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.14").
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 == '_auto':
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 _new_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
209
- """
210
- Send a POST request to the Salesforce token endpoint using http.client.
211
-
212
- :param payload: Dictionary of form-encoded OAuth parameters.
213
- :return: Parsed JSON response if successful, otherwise None.
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 endpoint: %s", self.token_endpoint)
227
- logger.trace("Request body: %s", body)
229
+ logger.trace("Request method: %s", method)
230
+ logger.trace("Request endpoint: %s", endpoint)
228
231
  logger.trace("Request headers: %s", headers)
229
- conn.request("POST", self.token_endpoint, body, headers)
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
- if response.status == 200:
235
- logger.trace("Token refresh successful.")
236
- logger.trace("Response body: %s", data)
237
- return json.loads(data)
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("Error during token request: %s", err)
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
- "Authorization": f"Bearer {self.access_token}",
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
- finally:
415
- logger.trace("Closing connection...")
416
- conn.close()
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
- self._refresh_token_if_needed()
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
- logger.error(
506
- "Patch Static Resource API request failed: %s %s",
507
- response.status,
508
- response.reason,
509
- )
510
- logger.debug("Response body: %s", data)
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
- except Exception as err:
513
- logger.exception("Error during patch request: %s", err)
484
+ if status_code == 200:
485
+ logger.debug("Patch Static Resource request successful.")
486
+ return json.loads(response_data)
514
487
 
515
- finally:
516
- logger.trace("Closing connection.")
517
- conn.close()
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
- Execute a GET request to the Salesforce Limits API.
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
- except Exception as err:
563
- logger.exception("Error during limits request: %s", err)
505
+ status, data = self._send_request("GET", endpoint, headers)
564
506
 
565
- finally:
566
- logger.debug("Closing connection...")
567
- conn.close()
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
- headers = {
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
- conn.request("GET", endpoint, headers=headers)
607
- response = conn.getresponse()
608
- data = response.read().decode("utf-8")
609
- self._http_resp_header_logic(response)
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 response.status == 200:
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 (%s)",
637
- response.status,
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
- if response.status == 200:
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 %s",
726
- response.status,
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
- parsed_url = urlparse(self.instance_url)
785
- conn = self._create_connection(parsed_url.netloc)
786
- batch_results = {}
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
- try:
789
- logger.trace("Request endpoint: %s", endpoint)
790
- logger.trace("Request headers: %s", headers)
791
- logger.trace("Request payload: %s", json.dumps(payload, indent=2))
792
-
793
- conn.request("POST", endpoint, json.dumps(payload), headers=headers)
794
- conn.sock.settimeout(60 * 10)
795
- response = conn.getresponse()
796
- data = response.read().decode("utf-8")
797
- self._http_resp_header_logic(response)
798
-
799
- if response.status == 200:
800
- logger.debug("Composite query successful.")
801
- logger.trace("Composite query full response: %s", data)
802
- results = json.loads(data).get("results", [])
803
- for i, result in enumerate(results):
804
- records = []
805
- if "result" in result and "records" in result["result"]:
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
- logger.trace("Query response: %s", result)
840
- else:
841
- logger.error(
842
- "Composite query failed with HTTP status %s (%s)",
843
- response.status,
844
- response.reason,
845
- )
846
- batch_results[keys[i]] = data
847
- logger.trace("Composite query response: %s", data)
848
- except Exception as err:
849
- logger.exception("Exception during composite query: %s", err)
850
- finally:
851
- logger.trace("Closing connection...")
852
- conn.close()
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
- for i in range(0, len(keys), 25):
862
- batch_keys = keys[i : i + 25]
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.")
@@ -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.")
@@ -3,5 +3,5 @@ requires-python = ">=3.9"
3
3
 
4
4
  [[package]]
5
5
  name = "sfq"
6
- version = "0.0.14"
6
+ version = "0.0.15"
7
7
  source = { editable = "." }
File without changes
File without changes
File without changes
File without changes
File without changes