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.
|
87
|
+
user_agent: str = "sfq/0.0.17",
|
92
88
|
sforce_client: str = "_auto",
|
93
|
-
proxy: str = "
|
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.
|
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, "
|
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 == "
|
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
|
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 =
|
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
|
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.
|
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,,
|
sfq-0.0.15.dist-info/RECORD
DELETED
@@ -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
|