sfq 0.0.31__py3-none-any.whl → 0.0.33__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 +223 -886
- sfq/_cometd.py +7 -10
- sfq/auth.py +401 -0
- sfq/crud.py +446 -0
- sfq/exceptions.py +54 -0
- sfq/http_client.py +319 -0
- sfq/query.py +398 -0
- sfq/soap.py +181 -0
- sfq/utils.py +196 -0
- {sfq-0.0.31.dist-info → sfq-0.0.33.dist-info}/METADATA +1 -1
- sfq-0.0.33.dist-info/RECORD +13 -0
- sfq-0.0.31.dist-info/RECORD +0 -6
- {sfq-0.0.31.dist-info → sfq-0.0.33.dist-info}/WHEEL +0 -0
sfq/__init__.py
CHANGED
@@ -2,90 +2,56 @@
|
|
2
2
|
.. include:: ../../README.md
|
3
3
|
"""
|
4
4
|
|
5
|
-
import base64
|
6
|
-
import http.client
|
7
|
-
import json
|
8
|
-
import logging
|
9
|
-
import os
|
10
|
-
import re
|
11
|
-
import time
|
12
|
-
import warnings
|
13
5
|
import webbrowser
|
14
|
-
import
|
15
|
-
from
|
16
|
-
|
17
|
-
|
18
|
-
from
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
for i, part in enumerate(parts):
|
64
|
-
if "=" in part:
|
65
|
-
key, value = part.split("=", 1)
|
66
|
-
if key.lower() in REDACT_KEYS:
|
67
|
-
parts[i] = f"{key}={REDACT_VALUE}"
|
68
|
-
return "&".join(parts)
|
69
|
-
return data
|
70
|
-
|
71
|
-
redacted_args = args
|
72
|
-
if args:
|
73
|
-
first = args[0]
|
74
|
-
if isinstance(first, str):
|
75
|
-
try:
|
76
|
-
loaded = json.loads(first)
|
77
|
-
first = loaded
|
78
|
-
except (json.JSONDecodeError, TypeError):
|
79
|
-
pass
|
80
|
-
redacted_first = _redact_sensitive(first)
|
81
|
-
redacted_args = (redacted_first,) + args[1:]
|
82
|
-
|
83
|
-
if self.isEnabledFor(TRACE):
|
84
|
-
self._log(TRACE, message, redacted_args, **kwargs)
|
85
|
-
|
86
|
-
|
87
|
-
logging.Logger.trace = trace
|
88
|
-
logger = logging.getLogger("sfq")
|
6
|
+
from typing import Any, Dict, Iterable, List, Literal, Optional
|
7
|
+
from urllib.parse import quote
|
8
|
+
|
9
|
+
# Import new modular components
|
10
|
+
from .auth import AuthManager
|
11
|
+
from .crud import CRUDClient
|
12
|
+
|
13
|
+
# Re-export all public classes and functions for backward compatibility
|
14
|
+
from .exceptions import (
|
15
|
+
APIError,
|
16
|
+
AuthenticationError,
|
17
|
+
ConfigurationError,
|
18
|
+
CRUDError,
|
19
|
+
HTTPError,
|
20
|
+
QueryError,
|
21
|
+
SFQException,
|
22
|
+
SOAPError,
|
23
|
+
)
|
24
|
+
from .http_client import HTTPClient
|
25
|
+
from .query import QueryClient
|
26
|
+
from .soap import SOAPClient
|
27
|
+
from .utils import get_logger
|
28
|
+
|
29
|
+
# Define public API for documentation tools
|
30
|
+
__all__ = [
|
31
|
+
"SFAuth",
|
32
|
+
# Exception classes
|
33
|
+
"SFQException",
|
34
|
+
"AuthenticationError",
|
35
|
+
"APIError",
|
36
|
+
"QueryError",
|
37
|
+
"CRUDError",
|
38
|
+
"SOAPError",
|
39
|
+
"HTTPError",
|
40
|
+
"ConfigurationError",
|
41
|
+
# Package metadata
|
42
|
+
"__version__",
|
43
|
+
]
|
44
|
+
|
45
|
+
__version__ = "0.0.33"
|
46
|
+
"""
|
47
|
+
### `__version__`
|
48
|
+
|
49
|
+
**The version of the sfq library.**
|
50
|
+
- Schema: `MAJOR.MINOR.PATCH`
|
51
|
+
- Used for debugging and compatibility checks
|
52
|
+
- Updated to reflect the current library version via CI/CD automation
|
53
|
+
"""
|
54
|
+
logger = get_logger("sfq")
|
89
55
|
|
90
56
|
|
91
57
|
class SFAuth:
|
@@ -93,14 +59,14 @@ class SFAuth:
|
|
93
59
|
self,
|
94
60
|
instance_url: str,
|
95
61
|
client_id: str,
|
96
|
-
|
97
|
-
|
62
|
+
client_secret: str,
|
63
|
+
refresh_token: str,
|
98
64
|
api_version: str = "v64.0",
|
99
65
|
token_endpoint: str = "/services/oauth2/token",
|
100
66
|
access_token: Optional[str] = None,
|
101
67
|
token_expiration_time: Optional[float] = None,
|
102
68
|
token_lifetime: int = 15 * 60,
|
103
|
-
user_agent: str = "sfq/0.0.
|
69
|
+
user_agent: str = "sfq/0.0.33",
|
104
70
|
sforce_client: str = "_auto",
|
105
71
|
proxy: str = "_auto",
|
106
72
|
) -> None:
|
@@ -120,7 +86,61 @@ class SFAuth:
|
|
120
86
|
:param sforce_client: Custom Application Identifier.
|
121
87
|
:param proxy: The proxy configuration, "_auto" to use environment.
|
122
88
|
"""
|
123
|
-
|
89
|
+
# Initialize the AuthManager with all authentication-related parameters
|
90
|
+
self._auth_manager = AuthManager(
|
91
|
+
instance_url=instance_url,
|
92
|
+
client_id=client_id,
|
93
|
+
refresh_token=refresh_token,
|
94
|
+
client_secret=str(client_secret).strip(),
|
95
|
+
api_version=api_version,
|
96
|
+
token_endpoint=token_endpoint,
|
97
|
+
access_token=access_token,
|
98
|
+
token_expiration_time=token_expiration_time,
|
99
|
+
token_lifetime=token_lifetime,
|
100
|
+
proxy=proxy,
|
101
|
+
)
|
102
|
+
|
103
|
+
# Initialize the HTTPClient with auth manager and user agent settings
|
104
|
+
self._http_client = HTTPClient(
|
105
|
+
auth_manager=self._auth_manager,
|
106
|
+
user_agent=user_agent,
|
107
|
+
sforce_client=sforce_client,
|
108
|
+
high_api_usage_threshold=80,
|
109
|
+
)
|
110
|
+
|
111
|
+
# Initialize the SOAPClient
|
112
|
+
self._soap_client = SOAPClient(
|
113
|
+
http_client=self._http_client,
|
114
|
+
api_version=api_version,
|
115
|
+
)
|
116
|
+
|
117
|
+
# Initialize the QueryClient
|
118
|
+
self._query_client = QueryClient(
|
119
|
+
http_client=self._http_client,
|
120
|
+
api_version=api_version,
|
121
|
+
)
|
122
|
+
|
123
|
+
# Initialize the CRUDClient
|
124
|
+
self._crud_client = CRUDClient(
|
125
|
+
http_client=self._http_client,
|
126
|
+
soap_client=self._soap_client,
|
127
|
+
api_version=api_version,
|
128
|
+
)
|
129
|
+
|
130
|
+
# Store version information
|
131
|
+
self.__version__ = "0.0.33"
|
132
|
+
"""
|
133
|
+
### `__version__`
|
134
|
+
|
135
|
+
**The version of the sfq library.**
|
136
|
+
- Schema: `MAJOR.MINOR.PATCH`
|
137
|
+
- Used for debugging and compatibility checks
|
138
|
+
- Updated to reflect the current library version via CI/CD automation
|
139
|
+
"""
|
140
|
+
|
141
|
+
# Property delegation to preserve all existing public attributes
|
142
|
+
@property
|
143
|
+
def instance_url(self) -> str:
|
124
144
|
"""
|
125
145
|
### `instance_url`
|
126
146
|
**The fully qualified Salesforce instance URL.**
|
@@ -133,8 +153,10 @@ class SFAuth:
|
|
133
153
|
- `https://sfq.my.salesforce.com`
|
134
154
|
- `https://sfq--dev.sandbox.my.salesforce.com`
|
135
155
|
"""
|
156
|
+
return self._auth_manager.instance_url
|
136
157
|
|
137
|
-
|
158
|
+
@property
|
159
|
+
def client_id(self) -> str:
|
138
160
|
"""
|
139
161
|
### `client_id`
|
140
162
|
**The OAuth client ID.**
|
@@ -143,8 +165,10 @@ class SFAuth:
|
|
143
165
|
- If using **Salesforce CLI**, this is `"PlatformCLI"`
|
144
166
|
- For other apps, find this value in the **Connected App details**
|
145
167
|
"""
|
168
|
+
return self._auth_manager.client_id
|
146
169
|
|
147
|
-
|
170
|
+
@property
|
171
|
+
def client_secret(self) -> str:
|
148
172
|
"""
|
149
173
|
### `client_secret`
|
150
174
|
**The OAuth client secret.**
|
@@ -153,8 +177,10 @@ class SFAuth:
|
|
153
177
|
- For **Salesforce CLI**, this is typically an empty string `""`
|
154
178
|
- For custom apps, locate it in the **Connected App settings**
|
155
179
|
"""
|
180
|
+
return self._auth_manager.client_secret
|
156
181
|
|
157
|
-
|
182
|
+
@property
|
183
|
+
def refresh_token(self) -> str:
|
158
184
|
"""
|
159
185
|
### `refresh_token`
|
160
186
|
**The OAuth refresh token.**
|
@@ -169,10 +195,11 @@ class SFAuth:
|
|
169
195
|
* For other apps, this value is returned during the **OAuth authorization flow**
|
170
196
|
* 📖 [Salesforce OAuth Flows Documentation](https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_flows.htm&type=5)
|
171
197
|
"""
|
198
|
+
return self._auth_manager.refresh_token
|
172
199
|
|
173
|
-
|
200
|
+
@property
|
201
|
+
def api_version(self) -> str:
|
174
202
|
"""
|
175
|
-
|
176
203
|
### `api_version`
|
177
204
|
|
178
205
|
**The Salesforce API version to use.**
|
@@ -180,10 +207,11 @@ class SFAuth:
|
|
180
207
|
* Must include the `"v"` prefix (e.g., `"v64.0"`)
|
181
208
|
* Periodically updated to align with new Salesforce releases
|
182
209
|
"""
|
210
|
+
return self._auth_manager.api_version
|
183
211
|
|
184
|
-
|
212
|
+
@property
|
213
|
+
def token_endpoint(self) -> str:
|
185
214
|
"""
|
186
|
-
|
187
215
|
### `token_endpoint`
|
188
216
|
|
189
217
|
**The token URL path for OAuth authentication.**
|
@@ -191,11 +219,12 @@ class SFAuth:
|
|
191
219
|
* Defaults to Salesforce's `.well-known/openid-configuration` for *token* endpoint
|
192
220
|
* Should start with a **leading slash**, e.g., `/services/oauth2/token`
|
193
221
|
* No customization is typical, but internal designs may use custom ApexRest endpoints
|
194
|
-
"""
|
195
|
-
|
196
|
-
self.access_token = access_token
|
197
222
|
"""
|
223
|
+
return self._auth_manager.token_endpoint
|
198
224
|
|
225
|
+
@property
|
226
|
+
def access_token(self) -> Optional[str]:
|
227
|
+
"""
|
199
228
|
### `access_token`
|
200
229
|
|
201
230
|
**The current OAuth access token.**
|
@@ -203,43 +232,49 @@ class SFAuth:
|
|
203
232
|
* Used to authorize API requests
|
204
233
|
* Does not include Bearer prefix, strictly the token
|
205
234
|
"""
|
235
|
+
# refresh token if required
|
206
236
|
|
207
|
-
self.
|
208
|
-
"""
|
237
|
+
return self._auth_manager.access_token
|
209
238
|
|
239
|
+
@property
|
240
|
+
def token_expiration_time(self) -> Optional[float]:
|
241
|
+
"""
|
210
242
|
### `token_expiration_time`
|
211
243
|
|
212
244
|
**Unix timestamp (in seconds) for access token expiration.**
|
213
245
|
|
214
246
|
* Managed automatically by the library
|
215
247
|
* Useful for checking when to refresh the token
|
216
|
-
"""
|
217
|
-
|
218
|
-
self.token_lifetime = token_lifetime
|
219
248
|
"""
|
249
|
+
return self._auth_manager.token_expiration_time
|
220
250
|
|
251
|
+
@property
|
252
|
+
def token_lifetime(self) -> int:
|
253
|
+
"""
|
221
254
|
### `token_lifetime`
|
222
255
|
|
223
256
|
**Access token lifespan in seconds.**
|
224
257
|
|
225
258
|
* Determined by your Connected App's session policies
|
226
259
|
* Used to calculate when to refresh the token
|
227
|
-
"""
|
228
|
-
|
229
|
-
self.user_agent = user_agent
|
230
260
|
"""
|
261
|
+
return self._auth_manager.token_lifetime
|
231
262
|
|
263
|
+
@property
|
264
|
+
def user_agent(self) -> str:
|
265
|
+
"""
|
232
266
|
### `user_agent`
|
233
267
|
|
234
268
|
**Custom User-Agent string for API calls.**
|
235
269
|
|
236
270
|
* Included in HTTP request headers
|
237
271
|
* Useful for identifying traffic in Salesforce `ApiEvent` logs
|
238
|
-
"""
|
239
|
-
|
240
|
-
self.sforce_client = str(sforce_client).replace(",", "")
|
241
272
|
"""
|
273
|
+
return self._http_client.user_agent
|
242
274
|
|
275
|
+
@property
|
276
|
+
def sforce_client(self) -> str:
|
277
|
+
"""
|
243
278
|
### `sforce_client`
|
244
279
|
|
245
280
|
**Custom application identifier.**
|
@@ -248,265 +283,52 @@ class SFAuth:
|
|
248
283
|
* Useful for identifying traffic in Event Log Files
|
249
284
|
* Commas are not allowed; will be stripped
|
250
285
|
"""
|
286
|
+
return self._http_client.sforce_client
|
251
287
|
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
if sforce_client == "_auto":
|
256
|
-
self.sforce_client = user_agent
|
257
|
-
|
258
|
-
if self.client_secret == "_deprecation_warning":
|
259
|
-
warnings.warn(
|
260
|
-
"The 'client_secret' parameter will be mandatory and positional arguments will change after 1 August 2025. "
|
261
|
-
"Please ensure explicit argument assignment and 'client_secret' inclusion when initializing the SFAuth object.",
|
262
|
-
DeprecationWarning,
|
263
|
-
stacklevel=2,
|
264
|
-
)
|
265
|
-
|
266
|
-
logger.debug(
|
267
|
-
"Will be SFAuth(instance_url, client_id, client_secret, refresh_token) starting 1 August 2025... but please just use named arguments.."
|
268
|
-
)
|
269
|
-
|
270
|
-
def _format_instance_url(self, instance_url) -> str:
|
271
|
-
"""
|
272
|
-
HTTPS is mandatory with Spring '21 release,
|
273
|
-
This method ensures that the instance URL is formatted correctly.
|
274
|
-
|
275
|
-
:param instance_url: The Salesforce instance URL.
|
276
|
-
:return: The formatted instance URL.
|
288
|
+
@property
|
289
|
+
def proxy(self) -> Optional[str]:
|
277
290
|
"""
|
278
|
-
|
279
|
-
return instance_url
|
280
|
-
if instance_url.startswith("http://"):
|
281
|
-
return instance_url.replace("http://", "https://")
|
282
|
-
return f"https://{instance_url}"
|
291
|
+
### `proxy`
|
283
292
|
|
284
|
-
|
285
|
-
"""
|
286
|
-
Automatically configure the proxy based on the environment or provided value.
|
287
|
-
"""
|
288
|
-
if proxy == "_auto":
|
289
|
-
self.proxy = os.environ.get("https_proxy") # HTTPs is mandatory
|
290
|
-
if self.proxy:
|
291
|
-
logger.debug("Auto-configured proxy: %s", self.proxy)
|
292
|
-
else:
|
293
|
-
self.proxy = proxy
|
294
|
-
logger.debug("Using configured proxy: %s", self.proxy)
|
293
|
+
**The proxy configuration.**
|
295
294
|
|
296
|
-
|
295
|
+
* Proxy URL for HTTP requests
|
296
|
+
* None if no proxy is configured
|
297
297
|
"""
|
298
|
-
|
299
|
-
|
300
|
-
This method constructs a dictionary containing the necessary parameters
|
301
|
-
for a token request using the refresh token grant type. It includes
|
302
|
-
the client ID, client secret, and refresh token if they are available.
|
298
|
+
return self._auth_manager.get_proxy_config()
|
303
299
|
|
304
|
-
|
305
|
-
|
300
|
+
@property
|
301
|
+
def org_id(self) -> Optional[str]:
|
306
302
|
"""
|
307
|
-
|
308
|
-
"grant_type": "refresh_token",
|
309
|
-
"client_id": self.client_id,
|
310
|
-
"client_secret": self.client_secret,
|
311
|
-
"refresh_token": self.refresh_token,
|
312
|
-
}
|
303
|
+
### `org_id`
|
313
304
|
|
314
|
-
|
315
|
-
logger.warning(
|
316
|
-
"The SFQ library is making a breaking change (2025-AUG-1) to require the 'client_secret' parameter to be assigned when initializing the SFAuth object. "
|
317
|
-
"In addition, positional arguments will change. Please ensure explicit argument assignment and 'client_secret' inclusion when initializing the SFAuth object to avoid impact."
|
318
|
-
)
|
319
|
-
payload.pop("client_secret")
|
320
|
-
|
321
|
-
if not self.client_secret:
|
322
|
-
payload.pop("client_secret")
|
323
|
-
|
324
|
-
return payload
|
325
|
-
|
326
|
-
def _create_connection(self, netloc: str) -> http.client.HTTPConnection:
|
327
|
-
"""
|
328
|
-
Create a connection using HTTP or HTTPS, with optional proxy support.
|
329
|
-
|
330
|
-
:param netloc: The target host and port from the parsed instance URL.
|
331
|
-
:return: An HTTP(S)Connection object.
|
332
|
-
"""
|
333
|
-
if self.proxy:
|
334
|
-
proxy_url = urlparse(self.proxy)
|
335
|
-
logger.trace("Using proxy: %s", self.proxy)
|
336
|
-
conn = http.client.HTTPSConnection(proxy_url.hostname, proxy_url.port)
|
337
|
-
conn.set_tunnel(netloc)
|
338
|
-
logger.trace("Using proxy tunnel to %s", netloc)
|
339
|
-
else:
|
340
|
-
conn = http.client.HTTPSConnection(netloc)
|
341
|
-
logger.trace("Direct connection to %s", netloc)
|
342
|
-
return conn
|
305
|
+
**The Salesforce organization ID.**
|
343
306
|
|
344
|
-
|
345
|
-
|
346
|
-
method: str,
|
347
|
-
endpoint: str,
|
348
|
-
headers: Dict[str, str],
|
349
|
-
body: Optional[str] = None,
|
350
|
-
) -> Tuple[Optional[int], Optional[str]]:
|
307
|
+
* Extracted from token response during authentication
|
308
|
+
* Available after successful token refresh
|
351
309
|
"""
|
352
|
-
|
310
|
+
return self._auth_manager.org_id
|
353
311
|
|
354
|
-
|
355
|
-
|
356
|
-
:param headers: HTTP headers.
|
357
|
-
:param body: Optional request body.
|
358
|
-
:param timeout: Optional timeout in seconds.
|
359
|
-
:return: Tuple of HTTP status code and response body as a string.
|
312
|
+
@property
|
313
|
+
def user_id(self) -> Optional[str]:
|
360
314
|
"""
|
361
|
-
|
362
|
-
conn = self._create_connection(parsed_url.netloc)
|
363
|
-
|
364
|
-
try:
|
365
|
-
logger.trace("Request method: %s", method)
|
366
|
-
logger.trace("Request endpoint: %s", endpoint)
|
367
|
-
logger.trace("Request headers: %s", headers)
|
368
|
-
if body:
|
369
|
-
logger.trace("Request body: %s", body)
|
370
|
-
|
371
|
-
conn.request(method, endpoint, body=body, headers=headers)
|
372
|
-
response = conn.getresponse()
|
373
|
-
self._http_resp_header_logic(response)
|
374
|
-
|
375
|
-
data = response.read().decode("utf-8")
|
376
|
-
logger.trace("Response status: %s", response.status)
|
377
|
-
logger.trace("Response body: %s", data)
|
378
|
-
return response.status, data
|
315
|
+
### `user_id`
|
379
316
|
|
380
|
-
|
381
|
-
logger.exception("HTTP request failed: %s", err)
|
382
|
-
return None, None
|
317
|
+
**The Salesforce user ID.**
|
383
318
|
|
384
|
-
|
385
|
-
|
386
|
-
conn.close()
|
387
|
-
|
388
|
-
def _new_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
389
|
-
"""
|
390
|
-
Perform a new token request using the provided payload.
|
391
|
-
|
392
|
-
:param payload: Payload for the token request.
|
393
|
-
:return: Parsed JSON response or None on failure.
|
319
|
+
* Extracted from token response during authentication
|
320
|
+
* Available after successful token refresh
|
394
321
|
"""
|
395
|
-
|
396
|
-
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
397
|
-
del headers["Authorization"]
|
398
|
-
|
399
|
-
body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
400
|
-
status, data = self._send_request("POST", self.token_endpoint, headers, body)
|
401
|
-
|
402
|
-
if status == 200:
|
403
|
-
logger.trace("Token refresh successful.")
|
404
|
-
return json.loads(data)
|
405
|
-
|
406
|
-
if status:
|
407
|
-
logger.error("Token refresh failed: %s", status)
|
408
|
-
logger.debug("Response body: %s", data)
|
409
|
-
|
410
|
-
return None
|
411
|
-
|
412
|
-
def _http_resp_header_logic(self, response: http.client.HTTPResponse) -> None:
|
413
|
-
"""
|
414
|
-
Perform additional logic based on the HTTP response headers.
|
415
|
-
|
416
|
-
:param response: The HTTP response object.
|
417
|
-
:return: None
|
418
|
-
"""
|
419
|
-
logger.trace(
|
420
|
-
"Response status: %s, reason: %s", response.status, response.reason
|
421
|
-
)
|
422
|
-
headers = response.getheaders()
|
423
|
-
headers_list = [(k, v) for k, v in headers if not v.startswith("BrowserId=")]
|
424
|
-
logger.trace("Response headers: %s", headers_list)
|
425
|
-
for key, value in headers_list:
|
426
|
-
if key == "Sforce-Limit-Info":
|
427
|
-
current_api_calls = int(value.split("=")[1].split("/")[0])
|
428
|
-
maximum_api_calls = int(value.split("=")[1].split("/")[1])
|
429
|
-
usage_percentage = round(current_api_calls / maximum_api_calls * 100, 2)
|
430
|
-
if usage_percentage > self._high_api_usage_threshold:
|
431
|
-
logger.warning(
|
432
|
-
"High API usage: %s/%s (%s%%)",
|
433
|
-
current_api_calls,
|
434
|
-
maximum_api_calls,
|
435
|
-
usage_percentage,
|
436
|
-
)
|
437
|
-
else:
|
438
|
-
logger.debug(
|
439
|
-
"API usage: %s/%s (%s%%)",
|
440
|
-
current_api_calls,
|
441
|
-
maximum_api_calls,
|
442
|
-
usage_percentage,
|
443
|
-
)
|
322
|
+
return self._auth_manager.user_id
|
444
323
|
|
324
|
+
# Token refresh method that delegates to HTTP client
|
445
325
|
def _refresh_token_if_needed(self) -> Optional[str]:
|
446
326
|
"""
|
447
327
|
Automatically refresh the access token if it has expired or is missing.
|
448
328
|
|
449
329
|
:return: A valid access token or None if refresh failed.
|
450
330
|
"""
|
451
|
-
|
452
|
-
return self.access_token
|
453
|
-
|
454
|
-
logger.trace("Access token expired or missing, refreshing...")
|
455
|
-
payload = self._prepare_payload()
|
456
|
-
token_data = self._new_token_request(payload)
|
457
|
-
|
458
|
-
if token_data:
|
459
|
-
self.access_token = token_data.get("access_token")
|
460
|
-
issued_at = token_data.get("issued_at")
|
461
|
-
|
462
|
-
try:
|
463
|
-
self.org_id = token_data.get("id").split("/")[4]
|
464
|
-
self.user_id = token_data.get("id").split("/")[5]
|
465
|
-
logger.trace(
|
466
|
-
"Authenticated as user %s for org %s (%s)",
|
467
|
-
self.user_id,
|
468
|
-
self.org_id,
|
469
|
-
token_data.get("instance_url"),
|
470
|
-
)
|
471
|
-
except (IndexError, KeyError):
|
472
|
-
logger.error("Failed to extract org/user IDs from token response.")
|
473
|
-
|
474
|
-
if self.access_token and issued_at:
|
475
|
-
self.token_expiration_time = int(issued_at) + self.token_lifetime
|
476
|
-
logger.trace("New token expires at %s", self.token_expiration_time)
|
477
|
-
return self.access_token
|
478
|
-
|
479
|
-
logger.error("Failed to obtain access token.")
|
480
|
-
return None
|
481
|
-
|
482
|
-
def _get_common_headers(self, recursive_call: bool = False) -> Dict[str, str]:
|
483
|
-
"""
|
484
|
-
Generate common headers for API requests.
|
485
|
-
|
486
|
-
:return: A dictionary of common headers.
|
487
|
-
"""
|
488
|
-
if not recursive_call:
|
489
|
-
self._refresh_token_if_needed()
|
490
|
-
|
491
|
-
return {
|
492
|
-
"Authorization": f"Bearer {self.access_token}",
|
493
|
-
"User-Agent": self.user_agent,
|
494
|
-
"Sforce-Call-Options": f"client={self.sforce_client}",
|
495
|
-
"Accept": "application/json",
|
496
|
-
"Content-Type": "application/json",
|
497
|
-
}
|
498
|
-
|
499
|
-
def _is_token_expired(self) -> bool:
|
500
|
-
"""
|
501
|
-
Check if the access token has expired.
|
502
|
-
|
503
|
-
:return: True if token is expired or missing, False otherwise.
|
504
|
-
"""
|
505
|
-
try:
|
506
|
-
return time.time() >= float(self.token_expiration_time)
|
507
|
-
except (TypeError, ValueError):
|
508
|
-
logger.warning("Token expiration check failed. Treating token as expired.")
|
509
|
-
return True
|
331
|
+
return self._http_client.refresh_token_and_update_auth()
|
510
332
|
|
511
333
|
def read_static_resource_name(
|
512
334
|
self, resource_name: str, namespace: Optional[str] = None
|
@@ -518,25 +340,7 @@ class SFAuth:
|
|
518
340
|
:param namespace: Namespace of the static resource to read (default is None).
|
519
341
|
:return: Static resource content or None on failure.
|
520
342
|
"""
|
521
|
-
|
522
|
-
query = f"SELECT Id FROM StaticResource WHERE Name = '{_safe_resource_name}'"
|
523
|
-
if namespace:
|
524
|
-
namespace = quote(namespace, safe="")
|
525
|
-
query += f" AND NamespacePrefix = '{namespace}'"
|
526
|
-
query += " LIMIT 1"
|
527
|
-
_static_resource_id_response = self.query(query)
|
528
|
-
|
529
|
-
if (
|
530
|
-
_static_resource_id_response
|
531
|
-
and _static_resource_id_response.get("records")
|
532
|
-
and len(_static_resource_id_response["records"]) > 0
|
533
|
-
):
|
534
|
-
return self.read_static_resource_id(
|
535
|
-
_static_resource_id_response["records"][0].get("Id")
|
536
|
-
)
|
537
|
-
|
538
|
-
logger.error(f"Failed to read static resource with name {_safe_resource_name}.")
|
539
|
-
return None
|
343
|
+
return self._crud_client.read_static_resource_name(resource_name, namespace)
|
540
344
|
|
541
345
|
def read_static_resource_id(self, resource_id: str) -> Optional[str]:
|
542
346
|
"""
|
@@ -545,16 +349,7 @@ class SFAuth:
|
|
545
349
|
:param resource_id: ID of the static resource to read.
|
546
350
|
:return: Static resource content or None on failure.
|
547
351
|
"""
|
548
|
-
|
549
|
-
headers = self._get_common_headers()
|
550
|
-
status, data = self._send_request("GET", endpoint, headers)
|
551
|
-
|
552
|
-
if status == 200:
|
553
|
-
logger.debug("Static resource fetched successfully.")
|
554
|
-
return data
|
555
|
-
|
556
|
-
logger.error("Failed to fetch static resource: %s", status)
|
557
|
-
return None
|
352
|
+
return self._crud_client.read_static_resource_id(resource_id)
|
558
353
|
|
559
354
|
def update_static_resource_name(
|
560
355
|
self, resource_name: str, data: str, namespace: Optional[str] = None
|
@@ -567,28 +362,9 @@ class SFAuth:
|
|
567
362
|
:param namespace: Optional namespace to search for the static resource.
|
568
363
|
:return: Static resource content or None on failure.
|
569
364
|
"""
|
570
|
-
|
571
|
-
|
572
|
-
if namespace:
|
573
|
-
namespace = quote(namespace, safe="")
|
574
|
-
query += f" AND NamespacePrefix = '{namespace}'"
|
575
|
-
query += " LIMIT 1"
|
576
|
-
|
577
|
-
static_resource_id_response = self.query(query)
|
578
|
-
|
579
|
-
if (
|
580
|
-
static_resource_id_response
|
581
|
-
and static_resource_id_response.get("records")
|
582
|
-
and len(static_resource_id_response["records"]) > 0
|
583
|
-
):
|
584
|
-
return self.update_static_resource_id(
|
585
|
-
static_resource_id_response["records"][0].get("Id"), data
|
586
|
-
)
|
587
|
-
|
588
|
-
logger.error(
|
589
|
-
f"Failed to update static resource with name {safe_resource_name}."
|
365
|
+
return self._crud_client.update_static_resource_name(
|
366
|
+
resource_name, data, namespace
|
590
367
|
)
|
591
|
-
return None
|
592
368
|
|
593
369
|
def update_static_resource_id(
|
594
370
|
self, resource_id: str, data: str
|
@@ -600,31 +376,7 @@ class SFAuth:
|
|
600
376
|
:param data: Content to update the static resource with.
|
601
377
|
:return: Parsed JSON response or None on failure.
|
602
378
|
"""
|
603
|
-
|
604
|
-
|
605
|
-
endpoint = (
|
606
|
-
f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
|
607
|
-
)
|
608
|
-
headers = self._get_common_headers()
|
609
|
-
|
610
|
-
status_code, response_data = self._send_request(
|
611
|
-
method="PATCH",
|
612
|
-
endpoint=endpoint,
|
613
|
-
headers=headers,
|
614
|
-
body=json.dumps(payload),
|
615
|
-
)
|
616
|
-
|
617
|
-
if status_code == 200:
|
618
|
-
logger.debug("Patch Static Resource request successful.")
|
619
|
-
return json.loads(response_data)
|
620
|
-
|
621
|
-
logger.error(
|
622
|
-
"Patch Static Resource API request failed: %s",
|
623
|
-
status_code,
|
624
|
-
)
|
625
|
-
logger.debug("Response body: %s", response_data)
|
626
|
-
|
627
|
-
return None
|
379
|
+
return self._crud_client.update_static_resource_id(resource_id, data)
|
628
380
|
|
629
381
|
def limits(self) -> Optional[Dict[str, Any]]:
|
630
382
|
"""
|
@@ -633,51 +385,21 @@ class SFAuth:
|
|
633
385
|
:return: Parsed JSON response or None on failure.
|
634
386
|
"""
|
635
387
|
endpoint = f"/services/data/{self.api_version}/limits"
|
636
|
-
headers = self._get_common_headers()
|
637
388
|
|
638
|
-
|
389
|
+
# Ensure we have a valid token
|
390
|
+
self._refresh_token_if_needed()
|
391
|
+
|
392
|
+
status, data = self._http_client.send_authenticated_request("GET", endpoint)
|
639
393
|
|
640
394
|
if status == 200:
|
395
|
+
import json
|
396
|
+
|
641
397
|
logger.debug("Limits fetched successfully.")
|
642
398
|
return json.loads(data)
|
643
399
|
|
644
400
|
logger.error("Failed to fetch limits: %s", status)
|
645
401
|
return None
|
646
402
|
|
647
|
-
def _paginate_query_result(self, initial_result: dict, headers: dict) -> dict:
|
648
|
-
"""
|
649
|
-
Helper to paginate Salesforce query results (for both query and cquery).
|
650
|
-
Returns a dict with all records combined.
|
651
|
-
"""
|
652
|
-
records = list(initial_result.get("records", []))
|
653
|
-
done = initial_result.get("done", True)
|
654
|
-
next_url = initial_result.get("nextRecordsUrl")
|
655
|
-
total_size = initial_result.get("totalSize", len(records))
|
656
|
-
|
657
|
-
while not done and next_url:
|
658
|
-
status_code, data = self._send_request(
|
659
|
-
method="GET",
|
660
|
-
endpoint=next_url,
|
661
|
-
headers=headers,
|
662
|
-
)
|
663
|
-
if status_code == 200:
|
664
|
-
next_result = json.loads(data)
|
665
|
-
records.extend(next_result.get("records", []))
|
666
|
-
done = next_result.get("done", True)
|
667
|
-
next_url = next_result.get("nextRecordsUrl")
|
668
|
-
total_size = next_result.get("totalSize", total_size)
|
669
|
-
else:
|
670
|
-
logger.error("Failed to fetch next records: %s", data)
|
671
|
-
break
|
672
|
-
|
673
|
-
paginated = dict(initial_result)
|
674
|
-
paginated["records"] = records
|
675
|
-
paginated["done"] = done
|
676
|
-
paginated["totalSize"] = total_size
|
677
|
-
if "nextRecordsUrl" in paginated:
|
678
|
-
del paginated["nextRecordsUrl"]
|
679
|
-
return paginated
|
680
|
-
|
681
403
|
def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
|
682
404
|
"""
|
683
405
|
Execute a SOQL query using the REST or Tooling API.
|
@@ -686,39 +408,7 @@ class SFAuth:
|
|
686
408
|
:param tooling: If True, use the Tooling API endpoint.
|
687
409
|
:return: Parsed JSON response or None on failure.
|
688
410
|
"""
|
689
|
-
|
690
|
-
endpoint += "tooling/query" if tooling else "query"
|
691
|
-
query_string = f"?q={quote(query)}"
|
692
|
-
endpoint += query_string
|
693
|
-
headers = self._get_common_headers()
|
694
|
-
|
695
|
-
try:
|
696
|
-
status_code, data = self._send_request(
|
697
|
-
method="GET",
|
698
|
-
endpoint=endpoint,
|
699
|
-
headers=headers,
|
700
|
-
)
|
701
|
-
if status_code == 200:
|
702
|
-
result = json.loads(data)
|
703
|
-
paginated = self._paginate_query_result(result, headers)
|
704
|
-
logger.debug(
|
705
|
-
"Query successful, returned %s records: %r",
|
706
|
-
paginated.get("totalSize"),
|
707
|
-
query,
|
708
|
-
)
|
709
|
-
logger.trace("Query full response: %s", paginated)
|
710
|
-
return paginated
|
711
|
-
else:
|
712
|
-
logger.debug("Query failed: %r", query)
|
713
|
-
logger.error(
|
714
|
-
"Query failed with HTTP status %s",
|
715
|
-
status_code,
|
716
|
-
)
|
717
|
-
logger.debug("Query response: %s", data)
|
718
|
-
except Exception as err:
|
719
|
-
logger.exception("Exception during query: %s", err)
|
720
|
-
|
721
|
-
return None
|
411
|
+
return self._query_client.query(query, tooling)
|
722
412
|
|
723
413
|
def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
|
724
414
|
"""
|
@@ -727,7 +417,7 @@ class SFAuth:
|
|
727
417
|
:param query: The SOQL query string.
|
728
418
|
:return: Parsed JSON response or None on failure.
|
729
419
|
"""
|
730
|
-
return self.
|
420
|
+
return self._query_client.tooling_query(query)
|
731
421
|
|
732
422
|
def get_sobject_prefixes(
|
733
423
|
self, key_type: Literal["id", "name"] = "id"
|
@@ -738,60 +428,13 @@ class SFAuth:
|
|
738
428
|
:param key_type: The type of key to return. Either 'id' (prefix) or 'name' (sObject).
|
739
429
|
:return: A dictionary mapping key prefixes to sObject names or None on failure.
|
740
430
|
"""
|
741
|
-
|
742
|
-
if key_type not in valid_key_types:
|
743
|
-
logger.error(
|
744
|
-
"Invalid key type: %s, must be one of: %s",
|
745
|
-
key_type,
|
746
|
-
", ".join(valid_key_types),
|
747
|
-
)
|
748
|
-
return None
|
749
|
-
|
750
|
-
endpoint = f"/services/data/{self.api_version}/sobjects/"
|
751
|
-
headers = self._get_common_headers()
|
752
|
-
|
753
|
-
prefixes = {}
|
754
|
-
|
755
|
-
try:
|
756
|
-
logger.trace("Request endpoint: %s", endpoint)
|
757
|
-
logger.trace("Request headers: %s", headers)
|
758
|
-
|
759
|
-
status_code, data = self._send_request(
|
760
|
-
method="GET",
|
761
|
-
endpoint=endpoint,
|
762
|
-
headers=headers,
|
763
|
-
)
|
764
|
-
|
765
|
-
if status_code == 200:
|
766
|
-
logger.debug("Key prefixes API request successful.")
|
767
|
-
logger.trace("Response body: %s", data)
|
768
|
-
for sobject in json.loads(data)["sobjects"]:
|
769
|
-
key_prefix = sobject.get("keyPrefix")
|
770
|
-
name = sobject.get("name")
|
771
|
-
if not key_prefix or not name:
|
772
|
-
continue
|
773
|
-
|
774
|
-
if key_type == "id":
|
775
|
-
prefixes[key_prefix] = name
|
776
|
-
elif key_type == "name":
|
777
|
-
prefixes[name] = key_prefix
|
778
|
-
|
779
|
-
logger.debug("Key prefixes: %s", prefixes)
|
780
|
-
return prefixes
|
781
|
-
|
782
|
-
logger.error(
|
783
|
-
"Key prefixes API request failed: %s",
|
784
|
-
status_code,
|
785
|
-
)
|
786
|
-
logger.debug("Response body: %s", data)
|
787
|
-
|
788
|
-
except Exception as err:
|
789
|
-
logger.exception("Exception during key prefixes API request: %s", err)
|
790
|
-
|
791
|
-
return None
|
431
|
+
return self._query_client.get_sobject_prefixes(key_type)
|
792
432
|
|
793
433
|
def cquery(
|
794
|
-
self,
|
434
|
+
self,
|
435
|
+
query_dict: Dict[str, str],
|
436
|
+
batch_size: int = 25,
|
437
|
+
max_workers: Optional[int] = None,
|
795
438
|
) -> Optional[Dict[str, Any]]:
|
796
439
|
"""
|
797
440
|
Execute multiple SOQL queries using the Composite Batch API with threading to reduce network overhead.
|
@@ -804,80 +447,13 @@ class SFAuth:
|
|
804
447
|
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
805
448
|
:return: Dict mapping the original keys to their corresponding batch response or None on failure.
|
806
449
|
"""
|
807
|
-
|
808
|
-
logger.warning("No queries to execute.")
|
809
|
-
return None
|
810
|
-
|
811
|
-
def _execute_batch(batch_keys, batch_queries):
|
812
|
-
endpoint = f"/services/data/{self.api_version}/composite/batch"
|
813
|
-
headers = self._get_common_headers()
|
814
|
-
|
815
|
-
payload = {
|
816
|
-
"haltOnError": False,
|
817
|
-
"batchRequests": [
|
818
|
-
{
|
819
|
-
"method": "GET",
|
820
|
-
"url": f"/services/data/{self.api_version}/query?q={quote(query)}",
|
821
|
-
}
|
822
|
-
for query in batch_queries
|
823
|
-
],
|
824
|
-
}
|
825
|
-
|
826
|
-
status_code, data = self._send_request(
|
827
|
-
method="POST",
|
828
|
-
endpoint=endpoint,
|
829
|
-
headers=headers,
|
830
|
-
body=json.dumps(payload),
|
831
|
-
)
|
832
|
-
|
833
|
-
batch_results = {}
|
834
|
-
if status_code == 200:
|
835
|
-
logger.debug("Composite query successful.")
|
836
|
-
logger.trace("Composite query full response: %s", data)
|
837
|
-
results = json.loads(data).get("results", [])
|
838
|
-
for i, result in enumerate(results):
|
839
|
-
key = batch_keys[i]
|
840
|
-
if result.get("statusCode") == 200 and "result" in result:
|
841
|
-
paginated = self._paginate_query_result(
|
842
|
-
result["result"], headers
|
843
|
-
)
|
844
|
-
batch_results[key] = paginated
|
845
|
-
else:
|
846
|
-
logger.error("Query failed for key %s: %s", key, result)
|
847
|
-
batch_results[key] = result
|
848
|
-
else:
|
849
|
-
logger.error(
|
850
|
-
"Composite query failed with HTTP status %s (%s)",
|
851
|
-
status_code,
|
852
|
-
data,
|
853
|
-
)
|
854
|
-
for i, key in enumerate(batch_keys):
|
855
|
-
batch_results[key] = data
|
856
|
-
logger.trace("Composite query response: %s", data)
|
857
|
-
|
858
|
-
return batch_results
|
859
|
-
|
860
|
-
keys = list(query_dict.keys())
|
861
|
-
results_dict = OrderedDict()
|
862
|
-
|
863
|
-
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
864
|
-
futures = []
|
865
|
-
BATCH_SIZE = batch_size
|
866
|
-
for i in range(0, len(keys), BATCH_SIZE):
|
867
|
-
batch_keys = keys[i : i + BATCH_SIZE]
|
868
|
-
batch_queries = [query_dict[key] for key in batch_keys]
|
869
|
-
futures.append(
|
870
|
-
executor.submit(_execute_batch, batch_keys, batch_queries)
|
871
|
-
)
|
872
|
-
|
873
|
-
for future in as_completed(futures):
|
874
|
-
results_dict.update(future.result())
|
875
|
-
|
876
|
-
logger.trace("Composite query results: %s", results_dict)
|
877
|
-
return results_dict
|
450
|
+
return self._query_client.cquery(query_dict, batch_size, max_workers)
|
878
451
|
|
879
452
|
def cdelete(
|
880
|
-
self,
|
453
|
+
self,
|
454
|
+
ids: Iterable[str],
|
455
|
+
batch_size: int = 200,
|
456
|
+
max_workers: Optional[int] = None,
|
881
457
|
) -> Optional[Dict[str, Any]]:
|
882
458
|
"""
|
883
459
|
Execute the Collections Delete API to delete multiple records using multithreading.
|
@@ -887,314 +463,72 @@ class SFAuth:
|
|
887
463
|
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
888
464
|
:return: Combined JSON response from all batches or None on complete failure.
|
889
465
|
"""
|
890
|
-
ids
|
891
|
-
chunks = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)]
|
892
|
-
|
893
|
-
def delete_chunk(chunk: List[str]) -> Optional[Dict[str, Any]]:
|
894
|
-
endpoint = f"/services/data/{self.api_version}/composite/sobjects?ids={','.join(chunk)}&allOrNone=false"
|
895
|
-
headers = self._get_common_headers()
|
896
|
-
|
897
|
-
status_code, resp_data = self._send_request(
|
898
|
-
method="DELETE",
|
899
|
-
endpoint=endpoint,
|
900
|
-
headers=headers,
|
901
|
-
)
|
902
|
-
|
903
|
-
if status_code == 200:
|
904
|
-
logger.debug("Collections delete API response without errors.")
|
905
|
-
return json.loads(resp_data)
|
906
|
-
else:
|
907
|
-
logger.error("Collections delete API request failed: %s", status_code)
|
908
|
-
logger.debug("Response body: %s", resp_data)
|
909
|
-
return None
|
910
|
-
|
911
|
-
results = []
|
912
|
-
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
913
|
-
futures = [executor.submit(delete_chunk, chunk) for chunk in chunks]
|
914
|
-
for future in as_completed(futures):
|
915
|
-
result = future.result()
|
916
|
-
if result:
|
917
|
-
results.append(result)
|
918
|
-
|
919
|
-
combined_response = [
|
920
|
-
item
|
921
|
-
for result in results
|
922
|
-
for item in (result if isinstance(result, list) else [result])
|
923
|
-
if isinstance(result, (dict, list))
|
924
|
-
]
|
925
|
-
return combined_response or None
|
466
|
+
return self._crud_client.cdelete(ids, batch_size, max_workers)
|
926
467
|
|
927
468
|
def _cupdate(
|
928
|
-
self,
|
469
|
+
self,
|
470
|
+
update_dict: Dict[str, Any],
|
471
|
+
batch_size: int = 25,
|
472
|
+
max_workers: Optional[int] = None,
|
929
473
|
) -> Optional[Dict[str, Any]]:
|
930
474
|
"""
|
931
475
|
Execute the Composite Update API to update multiple records.
|
932
476
|
|
933
|
-
: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.
|
934
|
-
{'001aj00000C8kJhAAJ': {'Subject': 'Easily updated via SFQ'}, '00aaj000006wtdcAAA': {'_': 'CaseComment', 'IsPublished': False}, '001aj0000002yJRCAY': {'_': 'IdeaComment', 'CommentBody': 'Hello World!'}}
|
477
|
+
: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.
|
935
478
|
:param batch_size: The number of records to update in each batch (default is 25).
|
479
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
936
480
|
:return: JSON response from the update request or None on failure.
|
937
481
|
"""
|
938
|
-
|
939
|
-
|
940
|
-
|
941
|
-
|
942
|
-
sobject_prefixes = {}
|
943
|
-
|
944
|
-
for key, record in update_dict.items():
|
945
|
-
sobject = record.copy().pop("_", None)
|
946
|
-
if not sobject and not sobject_prefixes:
|
947
|
-
sobject_prefixes = self.get_sobject_prefixes()
|
948
|
-
|
949
|
-
if not sobject:
|
950
|
-
sobject = str(sobject_prefixes.get(str(key[:3]), None))
|
951
|
-
|
952
|
-
compositeRequest_payload.append(
|
953
|
-
{
|
954
|
-
"method": "PATCH",
|
955
|
-
"url": f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
|
956
|
-
"referenceId": key,
|
957
|
-
"body": record,
|
958
|
-
}
|
959
|
-
)
|
960
|
-
|
961
|
-
chunks = [
|
962
|
-
compositeRequest_payload[i : i + batch_size]
|
963
|
-
for i in range(0, len(compositeRequest_payload), batch_size)
|
964
|
-
]
|
965
|
-
|
966
|
-
def update_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
967
|
-
payload = {"allOrNone": bool(allOrNone), "compositeRequest": chunk}
|
968
|
-
|
969
|
-
status_code, resp_data = self._send_request(
|
970
|
-
method="POST",
|
971
|
-
endpoint=endpoint,
|
972
|
-
headers=self._get_common_headers(),
|
973
|
-
body=json.dumps(payload),
|
974
|
-
)
|
975
|
-
|
976
|
-
if status_code == 200:
|
977
|
-
logger.debug("Composite update API response without errors.")
|
978
|
-
return json.loads(resp_data)
|
979
|
-
else:
|
980
|
-
logger.error("Composite update API request failed: %s", status_code)
|
981
|
-
logger.debug("Response body: %s", resp_data)
|
982
|
-
return None
|
983
|
-
|
984
|
-
results = []
|
985
|
-
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
986
|
-
futures = [executor.submit(update_chunk, chunk) for chunk in chunks]
|
987
|
-
for future in as_completed(futures):
|
988
|
-
result = future.result()
|
989
|
-
if result:
|
990
|
-
results.append(result)
|
991
|
-
|
992
|
-
combined_response = [
|
993
|
-
item
|
994
|
-
for result in results
|
995
|
-
for item in (result if isinstance(result, list) else [result])
|
996
|
-
if isinstance(result, (dict, list))
|
997
|
-
]
|
998
|
-
|
999
|
-
return combined_response or None
|
1000
|
-
|
1001
|
-
def _gen_soap_envelope(self, header: str, body: str, type: str) -> str:
|
482
|
+
return self._crud_client.cupdate(update_dict, batch_size, max_workers)
|
483
|
+
|
484
|
+
# SOAP methods delegated to SOAP client
|
485
|
+
def _gen_soap_envelope(self, header: str, body: str, api_type: str) -> str:
|
1002
486
|
"""Generates a full SOAP envelope with all required namespaces for Salesforce API."""
|
1003
|
-
|
1004
|
-
return (
|
1005
|
-
'<?xml version="1.0" encoding="UTF-8"?>'
|
1006
|
-
"<soapenv:Envelope "
|
1007
|
-
'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
|
1008
|
-
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
|
1009
|
-
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
|
1010
|
-
'xmlns="urn:enterprise.soap.sforce.com" '
|
1011
|
-
'xmlns:sf="urn:sobject.enterprise.soap.sforce.com">'
|
1012
|
-
f"{header}{body}"
|
1013
|
-
"</soapenv:Envelope>"
|
1014
|
-
)
|
1015
|
-
elif type == "tooling":
|
1016
|
-
return (
|
1017
|
-
'<?xml version="1.0" encoding="UTF-8"?>'
|
1018
|
-
"<soapenv:Envelope "
|
1019
|
-
'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
|
1020
|
-
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
|
1021
|
-
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
|
1022
|
-
'xmlns="urn:tooling.soap.sforce.com" '
|
1023
|
-
'xmlns:mns="urn:metadata.tooling.soap.sforce.com" '
|
1024
|
-
'xmlns:sf="urn:sobject.tooling.soap.sforce.com">'
|
1025
|
-
f"{header}{body}"
|
1026
|
-
"</soapenv:Envelope>"
|
1027
|
-
)
|
1028
|
-
raise ValueError(
|
1029
|
-
f"Unsupported API type: {type}. Must be 'enterprise' or 'tooling'."
|
1030
|
-
)
|
487
|
+
return self._soap_client.generate_soap_envelope(header, body, api_type)
|
1031
488
|
|
1032
489
|
def _gen_soap_header(self) -> str:
|
1033
490
|
"""This function generates the header for the SOAP request."""
|
1034
|
-
|
1035
|
-
|
1036
|
-
return
|
491
|
+
# Ensure we have a valid token
|
492
|
+
self._refresh_token_if_needed()
|
493
|
+
return self._soap_client.generate_soap_header(self.access_token)
|
1037
494
|
|
1038
495
|
def _extract_soap_result_fields(self, xml_string: str) -> Optional[Dict[str, Any]]:
|
1039
|
-
"""
|
1040
|
-
|
1041
|
-
"""
|
1042
|
-
|
1043
|
-
def strip_ns(tag):
|
1044
|
-
return tag.split("}", 1)[-1] if "}" in tag else tag
|
1045
|
-
|
1046
|
-
try:
|
1047
|
-
root = ET.fromstring(xml_string)
|
1048
|
-
results = []
|
1049
|
-
for result in root.iter():
|
1050
|
-
if result.tag.endswith("result"):
|
1051
|
-
out = {}
|
1052
|
-
for child in result:
|
1053
|
-
out[strip_ns(child.tag)] = child.text
|
1054
|
-
results.append(out)
|
1055
|
-
if not results:
|
1056
|
-
return None
|
1057
|
-
if len(results) == 1:
|
1058
|
-
return results[0]
|
1059
|
-
return results
|
1060
|
-
except ET.ParseError as e:
|
1061
|
-
logger.error("Failed to parse SOAP XML: %s", e)
|
1062
|
-
return None
|
496
|
+
"""Parse SOAP XML and extract all child fields from <result> as a dict."""
|
497
|
+
return self._soap_client.extract_soap_result_fields(xml_string)
|
1063
498
|
|
1064
499
|
def _gen_soap_body(self, sobject: str, method: str, data: Dict[str, Any]) -> str:
|
1065
500
|
"""Generates a compact SOAP request body for one or more records."""
|
1066
|
-
|
1067
|
-
if isinstance(data, dict):
|
1068
|
-
records = [data]
|
1069
|
-
else:
|
1070
|
-
records = data
|
1071
|
-
sobjects = "".join(
|
1072
|
-
f'<sObjects xsi:type="{sobject}">'
|
1073
|
-
+ "".join(f"<{k}>{v}</{k}>" for k, v in record.items())
|
1074
|
-
+ "</sObjects>"
|
1075
|
-
for record in records
|
1076
|
-
)
|
1077
|
-
return f"<soapenv:Body><{method}>{sobjects}</{method}></soapenv:Body>"
|
501
|
+
return self._soap_client.generate_soap_body(sobject, method, data)
|
1078
502
|
|
1079
503
|
def _xml_to_json(self, xml_string: str) -> Optional[Dict[str, Any]]:
|
1080
|
-
"""
|
1081
|
-
|
1082
|
-
|
1083
|
-
:param xml_string: The XML string to convert.
|
1084
|
-
:return: A dictionary representation of the XML or None on failure.
|
1085
|
-
"""
|
1086
|
-
try:
|
1087
|
-
root = ET.fromstring(xml_string)
|
1088
|
-
return self._xml_to_dict(root)
|
1089
|
-
except ET.ParseError as e:
|
1090
|
-
logger.error("Failed to parse XML: %s", e)
|
1091
|
-
return None
|
504
|
+
"""Convert an XML string to a JSON-like dictionary."""
|
505
|
+
return self._soap_client.xml_to_dict(xml_string)
|
1092
506
|
|
1093
|
-
def _xml_to_dict(self, element
|
1094
|
-
"""
|
1095
|
-
|
1096
|
-
|
1097
|
-
:param element: The XML Element to convert.
|
1098
|
-
:return: A dictionary representation of the XML Element.
|
1099
|
-
"""
|
1100
|
-
if len(element) == 0:
|
1101
|
-
return element.text or ""
|
1102
|
-
|
1103
|
-
result = {}
|
1104
|
-
for child in element:
|
1105
|
-
child_dict = self._xml_to_dict(child)
|
1106
|
-
if child.tag not in result:
|
1107
|
-
result[child.tag] = child_dict
|
1108
|
-
else:
|
1109
|
-
if not isinstance(result[child.tag], list):
|
1110
|
-
result[child.tag] = [result[child.tag]]
|
1111
|
-
result[child.tag].append(child_dict)
|
1112
|
-
return result
|
507
|
+
def _xml_to_dict(self, element) -> Dict[str, Any]:
|
508
|
+
"""Recursively convert an XML Element to a dictionary."""
|
509
|
+
return self._soap_client._xml_element_to_dict(element)
|
1113
510
|
|
1114
511
|
def _create( # I don't like this name, will think of a better one later...as such, not public.
|
1115
512
|
self,
|
1116
513
|
sobject: str,
|
1117
514
|
insert_list: List[Dict[str, Any]],
|
1118
515
|
batch_size: int = 200,
|
1119
|
-
max_workers: int = None,
|
516
|
+
max_workers: Optional[int] = None,
|
1120
517
|
api_type: Literal["enterprise", "tooling"] = "enterprise",
|
1121
518
|
) -> Optional[Dict[str, Any]]:
|
1122
519
|
"""
|
1123
520
|
Execute the Insert API to insert multiple records via SOAP calls.
|
1124
521
|
|
1125
522
|
:param sobject: The name of the sObject to insert into.
|
1126
|
-
:param insert_list: A list of dictionaries, each representing a record to insert.
|
523
|
+
:param insert_list: A list of dictionaries, each representing a record to insert.
|
1127
524
|
:param batch_size: The number of records to insert in each batch (default is 200).
|
1128
525
|
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
526
|
+
:param api_type: API type to use ('enterprise' or 'tooling').
|
1129
527
|
:return: JSON response from the insert request or None on failure.
|
1130
528
|
"""
|
1131
|
-
|
1132
|
-
|
1133
|
-
|
1134
|
-
endpoint += f"c/{self.api_version}"
|
1135
|
-
elif api_type == "tooling":
|
1136
|
-
endpoint += f"T/{self.api_version}"
|
1137
|
-
else:
|
1138
|
-
logger.error(
|
1139
|
-
"Invalid API type: %s. Must be one of: 'enterprise', 'tooling'.",
|
1140
|
-
api_type,
|
1141
|
-
)
|
1142
|
-
return None
|
1143
|
-
endpoint = endpoint.replace('/v', '/') # handle API versioning in the endpoint
|
1144
|
-
|
1145
|
-
if isinstance(insert_list, dict):
|
1146
|
-
insert_list = [insert_list]
|
1147
|
-
|
1148
|
-
chunks = [
|
1149
|
-
insert_list[i : i + batch_size]
|
1150
|
-
for i in range(0, len(insert_list), batch_size)
|
1151
|
-
]
|
1152
|
-
|
1153
|
-
def insert_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
1154
|
-
header = self._gen_soap_header()
|
1155
|
-
body = self._gen_soap_body(sobject=sobject, method="create", data=chunk)
|
1156
|
-
envelope = self._gen_soap_envelope(header=header, body=body, type=api_type)
|
1157
|
-
soap_headers = self._get_common_headers().copy()
|
1158
|
-
soap_headers["Content-Type"] = "text/xml; charset=UTF-8"
|
1159
|
-
soap_headers["SOAPAction"] = '""'
|
1160
|
-
|
1161
|
-
logger.trace("SOAP request envelope: %s", envelope)
|
1162
|
-
logger.trace("SOAP request headers: %s", soap_headers)
|
1163
|
-
status_code, resp_data = self._send_request(
|
1164
|
-
method="POST",
|
1165
|
-
endpoint=endpoint,
|
1166
|
-
headers=soap_headers,
|
1167
|
-
body=envelope,
|
1168
|
-
)
|
1169
|
-
|
1170
|
-
if status_code == 200:
|
1171
|
-
logger.debug("Insert API request successful.")
|
1172
|
-
logger.trace("Insert API response: %s", resp_data)
|
1173
|
-
result = self._extract_soap_result_fields(resp_data)
|
1174
|
-
if result:
|
1175
|
-
return result
|
1176
|
-
logger.error("Failed to extract fields from SOAP response.")
|
1177
|
-
else:
|
1178
|
-
logger.error("Insert API request failed: %s", status_code)
|
1179
|
-
logger.debug("Response body: %s", resp_data)
|
1180
|
-
return None
|
1181
|
-
|
1182
|
-
results = []
|
1183
|
-
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
1184
|
-
futures = [executor.submit(insert_chunk, chunk) for chunk in chunks]
|
1185
|
-
for future in as_completed(futures):
|
1186
|
-
result = future.result()
|
1187
|
-
if result:
|
1188
|
-
results.append(result)
|
1189
|
-
|
1190
|
-
combined_response = [
|
1191
|
-
item
|
1192
|
-
for result in results
|
1193
|
-
for item in (result if isinstance(result, list) else [result])
|
1194
|
-
if isinstance(result, (dict, list))
|
1195
|
-
]
|
1196
|
-
|
1197
|
-
return combined_response or None
|
529
|
+
return self._crud_client.create(
|
530
|
+
sobject, insert_list, batch_size, max_workers, api_type
|
531
|
+
)
|
1198
532
|
|
1199
533
|
def _debug_cleanup_apex_logs(self):
|
1200
534
|
"""
|
@@ -1220,8 +554,11 @@ class SFAuth:
|
|
1220
554
|
"""
|
1221
555
|
This function opens the Salesforce Frontdoor URL in the default web browser.
|
1222
556
|
"""
|
557
|
+
self._refresh_token_if_needed()
|
1223
558
|
if not self.access_token:
|
1224
|
-
|
559
|
+
logger.error("No access token available for frontdoor URL")
|
560
|
+
return
|
561
|
+
|
1225
562
|
sid = quote(self.access_token, safe="")
|
1226
563
|
frontdoor_url = f"{self.instance_url}/secur/frontdoor.jsp?sid={sid}"
|
1227
564
|
webbrowser.open(frontdoor_url)
|