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