sfq 0.0.15__py3-none-any.whl → 0.0.17__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
@@ -7,17 +7,13 @@ import time
7
7
  import warnings
8
8
  from collections import OrderedDict
9
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
10
- from typing import Any, Dict, Literal, Optional, List, Tuple
10
+ from typing import Any, Dict, Iterable, Literal, Optional, List, Tuple
11
11
  from urllib.parse import quote, urlparse
12
12
 
13
13
  TRACE = 5
14
14
  logging.addLevelName(TRACE, "TRACE")
15
15
 
16
16
 
17
- class ExperimentalWarning(Warning):
18
- pass
19
-
20
-
21
17
  def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
22
18
  """Custom TRACE level logging function with redaction."""
23
19
 
@@ -88,9 +84,9 @@ class SFAuth:
88
84
  access_token: Optional[str] = None,
89
85
  token_expiration_time: Optional[float] = None,
90
86
  token_lifetime: int = 15 * 60,
91
- user_agent: str = "sfq/0.0.15",
87
+ user_agent: str = "sfq/0.0.17",
92
88
  sforce_client: str = "_auto",
93
- proxy: str = "auto",
89
+ proxy: str = "_auto",
94
90
  ) -> None:
95
91
  """
96
92
  Initializes the SFAuth with necessary parameters.
@@ -104,9 +100,9 @@ class SFAuth:
104
100
  :param access_token: The access token for the current session (default is None).
105
101
  :param token_expiration_time: The expiration time of the access token (default is None).
106
102
  :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.15").
103
+ :param user_agent: Custom User-Agent string (default is "sfq/0.0.17").
108
104
  :param sforce_client: Custom Application Identifier (default is user_agent).
109
- :param proxy: The proxy configuration, "auto" to use environment (default is "auto").
105
+ :param proxy: The proxy configuration, "_auto" to use environment (default is "_auto").
110
106
  """
111
107
  self.instance_url = self._format_instance_url(instance_url)
112
108
  self.client_id = client_id
@@ -118,7 +114,7 @@ class SFAuth:
118
114
  self.token_expiration_time = token_expiration_time
119
115
  self.token_lifetime = token_lifetime
120
116
  self.user_agent = user_agent
121
- self.sforce_client = sforce_client
117
+ self.sforce_client = str(sforce_client).replace(",", "")
122
118
  self._auto_configure_proxy(proxy)
123
119
  self._high_api_usage_threshold = 80
124
120
 
@@ -138,6 +134,13 @@ class SFAuth:
138
134
  )
139
135
 
140
136
  def _format_instance_url(self, instance_url) -> str:
137
+ """
138
+ HTTPS is mandatory with Spring '21 release,
139
+ This method ensures that the instance URL is formatted correctly.
140
+
141
+ :param instance_url: The Salesforce instance URL.
142
+ :return: The formatted instance URL.
143
+ """
141
144
  if instance_url.startswith("https://"):
142
145
  return instance_url
143
146
  if instance_url.startswith("http://"):
@@ -148,8 +151,8 @@ class SFAuth:
148
151
  """
149
152
  Automatically configure the proxy based on the environment or provided value.
150
153
  """
151
- if proxy == "auto":
152
- self.proxy = os.environ.get("https_proxy")
154
+ if proxy == "_auto":
155
+ self.proxy = os.environ.get("https_proxy") # HTTPs is mandatory
153
156
  if self.proxy:
154
157
  logger.debug("Auto-configured proxy: %s", self.proxy)
155
158
  else:
@@ -210,7 +213,6 @@ class SFAuth:
210
213
  endpoint: str,
211
214
  headers: Dict[str, str],
212
215
  body: Optional[str] = None,
213
- timeout: Optional[int] = None,
214
216
  ) -> Tuple[Optional[int], Optional[str]]:
215
217
  """
216
218
  Unified request method with built-in logging and error handling.
@@ -256,7 +258,7 @@ class SFAuth:
256
258
  :param payload: Payload for the token request.
257
259
  :return: Parsed JSON response or None on failure.
258
260
  """
259
- headers = self._get_common_headers()
261
+ headers = self._get_common_headers(recursive_call=True)
260
262
  headers["Content-Type"] = "application/x-www-form-urlencoded"
261
263
  del headers["Authorization"]
262
264
 
@@ -343,16 +345,15 @@ class SFAuth:
343
345
  logger.error("Failed to obtain access token.")
344
346
  return None
345
347
 
346
- def _get_common_headers(self) -> Dict[str, str]:
348
+ def _get_common_headers(self, recursive_call: bool = False) -> Dict[str, str]:
347
349
  """
348
350
  Generate common headers for API requests.
349
351
 
350
352
  :return: A dictionary of common headers.
351
353
  """
352
- if not self.access_token and self.token_expiration_time is None:
353
- self.token_expiration_time = int(time.time())
354
+ if not recursive_call:
354
355
  self._refresh_token_if_needed()
355
-
356
+
356
357
  return {
357
358
  "Authorization": f"Bearer {self.access_token}",
358
359
  "User-Agent": self.user_agent,
@@ -361,8 +362,6 @@ class SFAuth:
361
362
  "Content-Type": "application/json",
362
363
  }
363
364
 
364
-
365
-
366
365
  def _is_token_expired(self) -> bool:
367
366
  """
368
367
  Check if the access token has expired.
@@ -648,7 +647,7 @@ class SFAuth:
648
647
  return None
649
648
 
650
649
  def cquery(
651
- self, query_dict: dict[str, str], max_workers: int = 10
650
+ self, query_dict: dict[str, str], batch_size: int = 25, max_workers: int = None
652
651
  ) -> Optional[Dict[str, Any]]:
653
652
  """
654
653
  Execute multiple SOQL queries using the Composite Batch API with threading to reduce network overhead.
@@ -657,7 +656,8 @@ class SFAuth:
657
656
  Each query (subrequest) is counted as a unique API request against Salesforce governance limits.
658
657
 
659
658
  :param query_dict: A dictionary of SOQL queries with keys as logical names and values as SOQL queries.
660
- :param max_workers: The maximum number of threads to spawn for concurrent execution (default is 10).
659
+ :param batch_size: The number of queries to include in each batch (default is 25).
660
+ :param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
661
661
  :return: Dict mapping the original keys to their corresponding batch response or None on failure.
662
662
  """
663
663
  if not query_dict:
@@ -757,3 +757,122 @@ class SFAuth:
757
757
 
758
758
  logger.trace("Composite query results: %s", results_dict)
759
759
  return results_dict
760
+
761
+ def cdelete(
762
+ self, ids: Iterable[str], batch_size: int = 200, max_workers: int = None
763
+ ) -> Optional[Dict[str, Any]]:
764
+ """
765
+ Execute the Collections Delete API to delete multiple records using multithreading.
766
+
767
+ :param ids: A list of record IDs to delete.
768
+ :param batch_size: The number of records to delete in each batch (default is 200).
769
+ :param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
770
+ :return: Combined JSON response from all batches or None on complete failure.
771
+ """
772
+ ids = list(ids)
773
+ chunks = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)]
774
+
775
+ def delete_chunk(chunk: List[str]) -> Optional[Dict[str, Any]]:
776
+ endpoint = f"/services/data/{self.api_version}/composite/sobjects?ids={','.join(chunk)}&allOrNone=false"
777
+ headers = self._get_common_headers()
778
+
779
+ status_code, resp_data = self._send_request(
780
+ method="DELETE",
781
+ endpoint=endpoint,
782
+ headers=headers,
783
+ )
784
+
785
+ if status_code == 200:
786
+ logger.debug("Collections delete API response without errors.")
787
+ return json.loads(resp_data)
788
+ else:
789
+ logger.error("Collections delete API request failed: %s", status_code)
790
+ logger.debug("Response body: %s", resp_data)
791
+ return None
792
+
793
+ results = []
794
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
795
+ futures = [executor.submit(delete_chunk, chunk) for chunk in chunks]
796
+ for future in as_completed(futures):
797
+ result = future.result()
798
+ if result:
799
+ results.append(result)
800
+
801
+ combined_response = [
802
+ item
803
+ for result in results
804
+ for item in (result if isinstance(result, list) else [result])
805
+ if isinstance(result, (dict, list))
806
+ ]
807
+ return combined_response or None
808
+
809
+ def _cupdate(self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None) -> Optional[Dict[str, Any]]:
810
+ """
811
+ Execute the Composite Update API to update multiple records.
812
+
813
+ :param update_dict: A dictionary of keys of records to be updated, and a dictionary of field-value pairs to be updated, with a special key '_' overriding the sObject type which is otherwise inferred from the key. Example:
814
+ {'001aj00000C8kJhAAJ': {'Subject': 'Easily updated via SFQ'}, '00aaj000006wtdcAAA': {'_': 'CaseComment', 'IsPublished': False}, '001aj0000002yJRCAY': {'_': 'IdeaComment', 'CommentBody': 'Hello World!'}}
815
+ :param batch_size: The number of records to update in each batch (default is 25).
816
+ :return: JSON response from the update request or None on failure.
817
+ """
818
+ allOrNone = False
819
+ endpoint = f"/services/data/{self.api_version}/composite"
820
+
821
+ compositeRequest_payload = []
822
+ sobject_prefixes = {}
823
+
824
+ for key, record in update_dict.items():
825
+ sobject = record.copy().pop("_", None)
826
+ if not sobject and not sobject_prefixes:
827
+ sobject_prefixes = self.get_sobject_prefixes()
828
+
829
+ sobject = str(sobject) or str(sobject_prefixes.get(str(key[:3]), None))
830
+
831
+ compositeRequest_payload.append(
832
+ {
833
+ 'method': 'PATCH',
834
+ 'url': f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
835
+ 'referenceId': key,
836
+ 'body': record,
837
+ }
838
+ )
839
+
840
+ chunks = [compositeRequest_payload[i:i+batch_size] for i in range(0, len(compositeRequest_payload), batch_size)]
841
+
842
+ def update_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
843
+ payload = {
844
+ "allOrNone": bool(allOrNone),
845
+ "compositeRequest": chunk
846
+ }
847
+
848
+ status_code, resp_data = self._send_request(
849
+ method="POST",
850
+ endpoint=endpoint,
851
+ headers=self._get_common_headers(),
852
+ body=json.dumps(payload),
853
+ )
854
+
855
+ if status_code == 200:
856
+ logger.debug("Composite update API response without errors.")
857
+ return json.loads(resp_data)
858
+ else:
859
+ logger.error("Composite update API request failed: %s", status_code)
860
+ logger.debug("Response body: %s", resp_data)
861
+ return None
862
+
863
+ results = []
864
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
865
+ futures = [executor.submit(update_chunk, chunk) for chunk in chunks]
866
+ for future in as_completed(futures):
867
+ result = future.result()
868
+ if result:
869
+ results.append(result)
870
+
871
+ combined_response = [
872
+ item
873
+ for result in results
874
+ for item in (result if isinstance(result, list) else [result])
875
+ if isinstance(result, (dict, list))
876
+ ]
877
+
878
+ return combined_response or None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.15
3
+ Version: 0.0.17
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
@@ -56,18 +56,6 @@ print(sf.query("SELECT Id FROM Account LIMIT 5"))
56
56
  print(sf.tooling_query("SELECT Id, FullName, Metadata FROM SandboxSettings LIMIT 5"))
57
57
  ```
58
58
 
59
- ### sObject Key Prefixes
60
-
61
- ```python
62
- # Key prefix via IDs
63
- print(sf.get_sobject_prefixes())
64
- >>> {'0Pp': 'AIApplication', '6S9': 'AIApplicationConfig', '9qd': 'AIInsightAction', '9bq': 'AIInsightFeedback', '0T2': 'AIInsightReason', '9qc': 'AIInsightValue', ...}
65
-
66
- # Key prefix via names
67
- print(sf.get_sobject_prefixes(key_type="name"))
68
- >>> {'AIApplication': '0Pp', 'AIApplicationConfig': '6S9', 'AIInsightAction': '9qd', 'AIInsightFeedback': '9bq', 'AIInsightReason': '0T2', 'AIInsightValue': '9qc', ...}
69
- ```
70
-
71
59
  ### Composite Batch Queries
72
60
 
73
61
  ```python
@@ -90,6 +78,13 @@ for subrequest_identifer, subrequest_response in batched_response.items():
90
78
  >>> "Frozen Users" returned 4082 records
91
79
  ```
92
80
 
81
+ ### Collection Deletions
82
+
83
+ ```python
84
+ response = sf.cdelete(['07La0000000bYgj', '07La0000000bYgk', '07La0000000bYgl'])
85
+ >>> [{'id': '07La0000000bYgj', 'success': True, 'errors': []}, {'id': '07La0000000bYgk', 'success': True, 'errors': []}, {'id': '07La0000000bYgl', 'success': True, 'errors': []}]
86
+ ```
87
+
93
88
  ### Static Resources
94
89
 
95
90
  ```python
@@ -103,6 +98,18 @@ print(f'Updated resource: {page}')
103
98
  sf.update_static_resource_id('081aj000009jUMXAA2', '<h1>It works!</h1>')
104
99
  ```
105
100
 
101
+ ### sObject Key Prefixes
102
+
103
+ ```python
104
+ # Key prefix via IDs
105
+ print(sf.get_sobject_prefixes())
106
+ >>> {'0Pp': 'AIApplication', '6S9': 'AIApplicationConfig', '9qd': 'AIInsightAction', '9bq': 'AIInsightFeedback', '0T2': 'AIInsightReason', '9qc': 'AIInsightValue', ...}
107
+
108
+ # Key prefix via names
109
+ print(sf.get_sobject_prefixes(key_type="name"))
110
+ >>> {'AIApplication': '0Pp', 'AIApplicationConfig': '6S9', 'AIInsightAction': '9qd', 'AIInsightFeedback': '9bq', 'AIInsightReason': '0T2', 'AIInsightValue': '9qc', ...}
111
+ ```
112
+
106
113
  ## How to Obtain Salesforce Tokens
107
114
 
108
115
  To use the `sfq` library, you'll need a **client ID** and **refresh token**. The easiest way to obtain these is by using the Salesforce CLI:
@@ -0,0 +1,6 @@
1
+ sfq/__init__.py,sha256=qtXrbjzTQL1nU2RaMbxWUDRFt4CAUKAkwwp-_9lqavE,35361
2
+ sfq/_cometd.py,sha256=XimQEubmJwUmbWe85TxH_cuhGvWVuiHHrVr41tguuiI,10508
3
+ sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sfq-0.0.17.dist-info/METADATA,sha256=XgKcuglHsZ1dN1rqNSgaWb-A-wf8-bStAMt2Gmj4GlE,6899
5
+ sfq-0.0.17.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ sfq-0.0.17.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- sfq/__init__.py,sha256=b70qbaov94JC7qWHuJA6X0i6O-H145YS-_vlyPzWig4,29895
2
- sfq/_cometd.py,sha256=XimQEubmJwUmbWe85TxH_cuhGvWVuiHHrVr41tguuiI,10508
3
- sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- sfq-0.0.15.dist-info/METADATA,sha256=ipG9mLqnwZwGp6gUGSbggP_LNl80YcGPpM1_fYlS7Vo,6598
5
- sfq-0.0.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
- sfq-0.0.15.dist-info/RECORD,,
File without changes