sfq 0.0.32__py3-none-any.whl → 0.0.34__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 +239 -913
- sfq/_cometd.py +7 -10
- sfq/auth.py +401 -0
- sfq/crud.py +514 -0
- sfq/debug_cleanup.py +71 -0
- sfq/exceptions.py +54 -0
- sfq/http_client.py +319 -0
- sfq/query.py +398 -0
- sfq/soap.py +186 -0
- sfq/utils.py +196 -0
- {sfq-0.0.32.dist-info → sfq-0.0.34.dist-info}/METADATA +1 -1
- sfq-0.0.34.dist-info/RECORD +14 -0
- sfq-0.0.32.dist-info/RECORD +0 -6
- {sfq-0.0.32.dist-info → sfq-0.0.34.dist-info}/WHEEL +0 -0
sfq/__init__.py
CHANGED
@@ -2,90 +2,57 @@
|
|
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
|
-
|
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
|
+
from .debug_cleanup import DebugCleanup
|
29
|
+
|
30
|
+
# Define public API for documentation tools
|
31
|
+
__all__ = [
|
32
|
+
"SFAuth",
|
33
|
+
# Exception classes
|
34
|
+
"SFQException",
|
35
|
+
"AuthenticationError",
|
36
|
+
"APIError",
|
37
|
+
"QueryError",
|
38
|
+
"CRUDError",
|
39
|
+
"SOAPError",
|
40
|
+
"HTTPError",
|
41
|
+
"ConfigurationError",
|
42
|
+
# Package metadata
|
43
|
+
"__version__",
|
44
|
+
]
|
45
|
+
|
46
|
+
__version__ = "0.0.34"
|
47
|
+
"""
|
48
|
+
### `__version__`
|
49
|
+
|
50
|
+
**The version of the sfq library.**
|
51
|
+
- Schema: `MAJOR.MINOR.PATCH`
|
52
|
+
- Used for debugging and compatibility checks
|
53
|
+
- Updated to reflect the current library version via CI/CD automation
|
54
|
+
"""
|
55
|
+
logger = get_logger("sfq")
|
89
56
|
|
90
57
|
|
91
58
|
class SFAuth:
|
@@ -93,14 +60,14 @@ class SFAuth:
|
|
93
60
|
self,
|
94
61
|
instance_url: str,
|
95
62
|
client_id: str,
|
96
|
-
|
97
|
-
|
63
|
+
client_secret: str,
|
64
|
+
refresh_token: str,
|
98
65
|
api_version: str = "v64.0",
|
99
66
|
token_endpoint: str = "/services/oauth2/token",
|
100
67
|
access_token: Optional[str] = None,
|
101
68
|
token_expiration_time: Optional[float] = None,
|
102
69
|
token_lifetime: int = 15 * 60,
|
103
|
-
user_agent: str = "sfq/0.0.
|
70
|
+
user_agent: str = "sfq/0.0.34",
|
104
71
|
sforce_client: str = "_auto",
|
105
72
|
proxy: str = "_auto",
|
106
73
|
) -> None:
|
@@ -120,7 +87,64 @@ class SFAuth:
|
|
120
87
|
:param sforce_client: Custom Application Identifier.
|
121
88
|
:param proxy: The proxy configuration, "_auto" to use environment.
|
122
89
|
"""
|
123
|
-
|
90
|
+
# Initialize the AuthManager with all authentication-related parameters
|
91
|
+
self._auth_manager = AuthManager(
|
92
|
+
instance_url=instance_url,
|
93
|
+
client_id=client_id,
|
94
|
+
refresh_token=refresh_token,
|
95
|
+
client_secret=str(client_secret).strip(),
|
96
|
+
api_version=api_version,
|
97
|
+
token_endpoint=token_endpoint,
|
98
|
+
access_token=access_token,
|
99
|
+
token_expiration_time=token_expiration_time,
|
100
|
+
token_lifetime=token_lifetime,
|
101
|
+
proxy=proxy,
|
102
|
+
)
|
103
|
+
|
104
|
+
# Initialize the HTTPClient with auth manager and user agent settings
|
105
|
+
self._http_client = HTTPClient(
|
106
|
+
auth_manager=self._auth_manager,
|
107
|
+
user_agent=user_agent,
|
108
|
+
sforce_client=sforce_client,
|
109
|
+
high_api_usage_threshold=80,
|
110
|
+
)
|
111
|
+
|
112
|
+
# Initialize the SOAPClient
|
113
|
+
self._soap_client = SOAPClient(
|
114
|
+
http_client=self._http_client,
|
115
|
+
api_version=api_version,
|
116
|
+
)
|
117
|
+
|
118
|
+
# Initialize the QueryClient
|
119
|
+
self._query_client = QueryClient(
|
120
|
+
http_client=self._http_client,
|
121
|
+
api_version=api_version,
|
122
|
+
)
|
123
|
+
|
124
|
+
# Initialize the CRUDClient
|
125
|
+
self._crud_client = CRUDClient(
|
126
|
+
http_client=self._http_client,
|
127
|
+
soap_client=self._soap_client,
|
128
|
+
api_version=api_version,
|
129
|
+
)
|
130
|
+
|
131
|
+
# Initialize the DebugCleanup
|
132
|
+
self._debug_cleanup = DebugCleanup(sf_auth=self)
|
133
|
+
|
134
|
+
# Store version information
|
135
|
+
self.__version__ = "0.0.34"
|
136
|
+
"""
|
137
|
+
### `__version__`
|
138
|
+
|
139
|
+
**The version of the sfq library.**
|
140
|
+
- Schema: `MAJOR.MINOR.PATCH`
|
141
|
+
- Used for debugging and compatibility checks
|
142
|
+
- Updated to reflect the current library version via CI/CD automation
|
143
|
+
"""
|
144
|
+
|
145
|
+
# Property delegation to preserve all existing public attributes
|
146
|
+
@property
|
147
|
+
def instance_url(self) -> str:
|
124
148
|
"""
|
125
149
|
### `instance_url`
|
126
150
|
**The fully qualified Salesforce instance URL.**
|
@@ -133,8 +157,10 @@ class SFAuth:
|
|
133
157
|
- `https://sfq.my.salesforce.com`
|
134
158
|
- `https://sfq--dev.sandbox.my.salesforce.com`
|
135
159
|
"""
|
160
|
+
return self._auth_manager.instance_url
|
136
161
|
|
137
|
-
|
162
|
+
@property
|
163
|
+
def client_id(self) -> str:
|
138
164
|
"""
|
139
165
|
### `client_id`
|
140
166
|
**The OAuth client ID.**
|
@@ -143,8 +169,10 @@ class SFAuth:
|
|
143
169
|
- If using **Salesforce CLI**, this is `"PlatformCLI"`
|
144
170
|
- For other apps, find this value in the **Connected App details**
|
145
171
|
"""
|
172
|
+
return self._auth_manager.client_id
|
146
173
|
|
147
|
-
|
174
|
+
@property
|
175
|
+
def client_secret(self) -> str:
|
148
176
|
"""
|
149
177
|
### `client_secret`
|
150
178
|
**The OAuth client secret.**
|
@@ -153,8 +181,10 @@ class SFAuth:
|
|
153
181
|
- For **Salesforce CLI**, this is typically an empty string `""`
|
154
182
|
- For custom apps, locate it in the **Connected App settings**
|
155
183
|
"""
|
184
|
+
return self._auth_manager.client_secret
|
156
185
|
|
157
|
-
|
186
|
+
@property
|
187
|
+
def refresh_token(self) -> str:
|
158
188
|
"""
|
159
189
|
### `refresh_token`
|
160
190
|
**The OAuth refresh token.**
|
@@ -169,20 +199,11 @@ class SFAuth:
|
|
169
199
|
* For other apps, this value is returned during the **OAuth authorization flow**
|
170
200
|
* 📖 [Salesforce OAuth Flows Documentation](https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_flows.htm&type=5)
|
171
201
|
"""
|
202
|
+
return self._auth_manager.refresh_token
|
172
203
|
|
173
|
-
|
174
|
-
|
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
|
204
|
+
@property
|
205
|
+
def api_version(self) -> str:
|
184
206
|
"""
|
185
|
-
|
186
207
|
### `api_version`
|
187
208
|
|
188
209
|
**The Salesforce API version to use.**
|
@@ -190,10 +211,11 @@ class SFAuth:
|
|
190
211
|
* Must include the `"v"` prefix (e.g., `"v64.0"`)
|
191
212
|
* Periodically updated to align with new Salesforce releases
|
192
213
|
"""
|
214
|
+
return self._auth_manager.api_version
|
193
215
|
|
194
|
-
|
216
|
+
@property
|
217
|
+
def token_endpoint(self) -> str:
|
195
218
|
"""
|
196
|
-
|
197
219
|
### `token_endpoint`
|
198
220
|
|
199
221
|
**The token URL path for OAuth authentication.**
|
@@ -201,11 +223,12 @@ class SFAuth:
|
|
201
223
|
* Defaults to Salesforce's `.well-known/openid-configuration` for *token* endpoint
|
202
224
|
* Should start with a **leading slash**, e.g., `/services/oauth2/token`
|
203
225
|
* No customization is typical, but internal designs may use custom ApexRest endpoints
|
204
|
-
"""
|
205
|
-
|
206
|
-
self.access_token = access_token
|
207
226
|
"""
|
227
|
+
return self._auth_manager.token_endpoint
|
208
228
|
|
229
|
+
@property
|
230
|
+
def access_token(self) -> Optional[str]:
|
231
|
+
"""
|
209
232
|
### `access_token`
|
210
233
|
|
211
234
|
**The current OAuth access token.**
|
@@ -213,43 +236,49 @@ class SFAuth:
|
|
213
236
|
* Used to authorize API requests
|
214
237
|
* Does not include Bearer prefix, strictly the token
|
215
238
|
"""
|
239
|
+
# refresh token if required
|
216
240
|
|
217
|
-
self.
|
218
|
-
"""
|
241
|
+
return self._auth_manager.access_token
|
219
242
|
|
243
|
+
@property
|
244
|
+
def token_expiration_time(self) -> Optional[float]:
|
245
|
+
"""
|
220
246
|
### `token_expiration_time`
|
221
247
|
|
222
248
|
**Unix timestamp (in seconds) for access token expiration.**
|
223
249
|
|
224
250
|
* Managed automatically by the library
|
225
251
|
* Useful for checking when to refresh the token
|
226
|
-
"""
|
227
|
-
|
228
|
-
self.token_lifetime = token_lifetime
|
229
252
|
"""
|
253
|
+
return self._auth_manager.token_expiration_time
|
230
254
|
|
255
|
+
@property
|
256
|
+
def token_lifetime(self) -> int:
|
257
|
+
"""
|
231
258
|
### `token_lifetime`
|
232
259
|
|
233
260
|
**Access token lifespan in seconds.**
|
234
261
|
|
235
262
|
* Determined by your Connected App's session policies
|
236
263
|
* Used to calculate when to refresh the token
|
237
|
-
"""
|
238
|
-
|
239
|
-
self.user_agent = user_agent
|
240
264
|
"""
|
265
|
+
return self._auth_manager.token_lifetime
|
241
266
|
|
267
|
+
@property
|
268
|
+
def user_agent(self) -> str:
|
269
|
+
"""
|
242
270
|
### `user_agent`
|
243
271
|
|
244
272
|
**Custom User-Agent string for API calls.**
|
245
273
|
|
246
274
|
* Included in HTTP request headers
|
247
275
|
* Useful for identifying traffic in Salesforce `ApiEvent` logs
|
248
|
-
"""
|
249
|
-
|
250
|
-
self.sforce_client = str(sforce_client).replace(",", "")
|
251
276
|
"""
|
277
|
+
return self._http_client.user_agent
|
252
278
|
|
279
|
+
@property
|
280
|
+
def sforce_client(self) -> str:
|
281
|
+
"""
|
253
282
|
### `sforce_client`
|
254
283
|
|
255
284
|
**Custom application identifier.**
|
@@ -258,265 +287,52 @@ class SFAuth:
|
|
258
287
|
* Useful for identifying traffic in Event Log Files
|
259
288
|
* Commas are not allowed; will be stripped
|
260
289
|
"""
|
290
|
+
return self._http_client.sforce_client
|
261
291
|
|
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:
|
292
|
+
@property
|
293
|
+
def proxy(self) -> Optional[str]:
|
281
294
|
"""
|
282
|
-
|
283
|
-
This method ensures that the instance URL is formatted correctly.
|
295
|
+
### `proxy`
|
284
296
|
|
285
|
-
|
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}"
|
297
|
+
**The proxy configuration.**
|
293
298
|
|
294
|
-
|
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]]:
|
299
|
+
* Proxy URL for HTTP requests
|
300
|
+
* None if no proxy is configured
|
307
301
|
"""
|
308
|
-
|
302
|
+
return self._auth_manager.get_proxy_config()
|
309
303
|
|
310
|
-
|
311
|
-
|
312
|
-
the client ID, client secret, and refresh token if they are available.
|
313
|
-
|
314
|
-
Returns:
|
315
|
-
Dict[str, Optional[str]]: A dictionary containing the payload for the token request.
|
316
|
-
"""
|
317
|
-
payload = {
|
318
|
-
"grant_type": "refresh_token",
|
319
|
-
"client_id": self.client_id,
|
320
|
-
"client_secret": self.client_secret,
|
321
|
-
"refresh_token": self.refresh_token,
|
322
|
-
}
|
323
|
-
|
324
|
-
if self.client_secret == "_deprecation_warning":
|
325
|
-
logger.warning(
|
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:
|
304
|
+
@property
|
305
|
+
def org_id(self) -> Optional[str]:
|
337
306
|
"""
|
338
|
-
|
307
|
+
### `org_id`
|
339
308
|
|
340
|
-
|
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)
|
380
|
-
|
381
|
-
conn.request(method, endpoint, body=body, headers=headers)
|
382
|
-
response = conn.getresponse()
|
383
|
-
self._http_resp_header_logic(response)
|
384
|
-
|
385
|
-
data = response.read().decode("utf-8")
|
386
|
-
logger.trace("Response status: %s", response.status)
|
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]]:
|
399
|
-
"""
|
400
|
-
Perform a new token request using the provided payload.
|
309
|
+
**The Salesforce organization ID.**
|
401
310
|
|
402
|
-
|
403
|
-
|
311
|
+
* Extracted from token response during authentication
|
312
|
+
* Available after successful token refresh
|
404
313
|
"""
|
405
|
-
|
406
|
-
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
407
|
-
del headers["Authorization"]
|
408
|
-
|
409
|
-
body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
410
|
-
status, data = self._send_request("POST", self.token_endpoint, headers, body)
|
411
|
-
|
412
|
-
if status == 200:
|
413
|
-
logger.trace("Token refresh successful.")
|
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
|
314
|
+
return self._auth_manager.org_id
|
421
315
|
|
422
|
-
|
316
|
+
@property
|
317
|
+
def user_id(self) -> Optional[str]:
|
423
318
|
"""
|
424
|
-
|
319
|
+
### `user_id`
|
425
320
|
|
426
|
-
|
427
|
-
|
321
|
+
**The Salesforce user ID.**
|
322
|
+
|
323
|
+
* Extracted from token response during authentication
|
324
|
+
* Available after successful token refresh
|
428
325
|
"""
|
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
|
-
)
|
326
|
+
return self._auth_manager.user_id
|
454
327
|
|
328
|
+
# Token refresh method that delegates to HTTP client
|
455
329
|
def _refresh_token_if_needed(self) -> Optional[str]:
|
456
330
|
"""
|
457
331
|
Automatically refresh the access token if it has expired or is missing.
|
458
332
|
|
459
333
|
:return: A valid access token or None if refresh failed.
|
460
334
|
"""
|
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
|
335
|
+
return self._http_client.refresh_token_and_update_auth()
|
520
336
|
|
521
337
|
def read_static_resource_name(
|
522
338
|
self, resource_name: str, namespace: Optional[str] = None
|
@@ -528,25 +344,7 @@ class SFAuth:
|
|
528
344
|
:param namespace: Namespace of the static resource to read (default is None).
|
529
345
|
:return: Static resource content or None on failure.
|
530
346
|
"""
|
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
|
347
|
+
return self._crud_client.read_static_resource_name(resource_name, namespace)
|
550
348
|
|
551
349
|
def read_static_resource_id(self, resource_id: str) -> Optional[str]:
|
552
350
|
"""
|
@@ -555,16 +353,7 @@ class SFAuth:
|
|
555
353
|
:param resource_id: ID of the static resource to read.
|
556
354
|
:return: Static resource content or None on failure.
|
557
355
|
"""
|
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
|
356
|
+
return self._crud_client.read_static_resource_id(resource_id)
|
568
357
|
|
569
358
|
def update_static_resource_name(
|
570
359
|
self, resource_name: str, data: str, namespace: Optional[str] = None
|
@@ -577,28 +366,9 @@ class SFAuth:
|
|
577
366
|
:param namespace: Optional namespace to search for the static resource.
|
578
367
|
:return: Static resource content or None on failure.
|
579
368
|
"""
|
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}."
|
369
|
+
return self._crud_client.update_static_resource_name(
|
370
|
+
resource_name, data, namespace
|
600
371
|
)
|
601
|
-
return None
|
602
372
|
|
603
373
|
def update_static_resource_id(
|
604
374
|
self, resource_id: str, data: str
|
@@ -610,31 +380,7 @@ class SFAuth:
|
|
610
380
|
:param data: Content to update the static resource with.
|
611
381
|
:return: Parsed JSON response or None on failure.
|
612
382
|
"""
|
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
|
383
|
+
return self._crud_client.update_static_resource_id(resource_id, data)
|
638
384
|
|
639
385
|
def limits(self) -> Optional[Dict[str, Any]]:
|
640
386
|
"""
|
@@ -643,51 +389,21 @@ class SFAuth:
|
|
643
389
|
:return: Parsed JSON response or None on failure.
|
644
390
|
"""
|
645
391
|
endpoint = f"/services/data/{self.api_version}/limits"
|
646
|
-
headers = self._get_common_headers()
|
647
392
|
|
648
|
-
|
393
|
+
# Ensure we have a valid token
|
394
|
+
self._refresh_token_if_needed()
|
395
|
+
|
396
|
+
status, data = self._http_client.send_authenticated_request("GET", endpoint)
|
649
397
|
|
650
398
|
if status == 200:
|
399
|
+
import json
|
400
|
+
|
651
401
|
logger.debug("Limits fetched successfully.")
|
652
402
|
return json.loads(data)
|
653
403
|
|
654
404
|
logger.error("Failed to fetch limits: %s", status)
|
655
405
|
return None
|
656
406
|
|
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
407
|
def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
|
692
408
|
"""
|
693
409
|
Execute a SOQL query using the REST or Tooling API.
|
@@ -696,39 +412,7 @@ class SFAuth:
|
|
696
412
|
:param tooling: If True, use the Tooling API endpoint.
|
697
413
|
:return: Parsed JSON response or None on failure.
|
698
414
|
"""
|
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
|
415
|
+
return self._query_client.query(query, tooling)
|
732
416
|
|
733
417
|
def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
|
734
418
|
"""
|
@@ -737,7 +421,7 @@ class SFAuth:
|
|
737
421
|
:param query: The SOQL query string.
|
738
422
|
:return: Parsed JSON response or None on failure.
|
739
423
|
"""
|
740
|
-
return self.
|
424
|
+
return self._query_client.tooling_query(query)
|
741
425
|
|
742
426
|
def get_sobject_prefixes(
|
743
427
|
self, key_type: Literal["id", "name"] = "id"
|
@@ -748,60 +432,13 @@ class SFAuth:
|
|
748
432
|
:param key_type: The type of key to return. Either 'id' (prefix) or 'name' (sObject).
|
749
433
|
:return: A dictionary mapping key prefixes to sObject names or None on failure.
|
750
434
|
"""
|
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
|
435
|
+
return self._query_client.get_sobject_prefixes(key_type)
|
802
436
|
|
803
437
|
def cquery(
|
804
|
-
self,
|
438
|
+
self,
|
439
|
+
query_dict: Dict[str, str],
|
440
|
+
batch_size: int = 25,
|
441
|
+
max_workers: Optional[int] = None,
|
805
442
|
) -> Optional[Dict[str, Any]]:
|
806
443
|
"""
|
807
444
|
Execute multiple SOQL queries using the Composite Batch API with threading to reduce network overhead.
|
@@ -814,80 +451,13 @@ class SFAuth:
|
|
814
451
|
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
815
452
|
:return: Dict mapping the original keys to their corresponding batch response or None on failure.
|
816
453
|
"""
|
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
|
454
|
+
return self._query_client.cquery(query_dict, batch_size, max_workers)
|
888
455
|
|
889
456
|
def cdelete(
|
890
|
-
self,
|
457
|
+
self,
|
458
|
+
ids: Iterable[str],
|
459
|
+
batch_size: int = 200,
|
460
|
+
max_workers: Optional[int] = None,
|
891
461
|
) -> Optional[Dict[str, Any]]:
|
892
462
|
"""
|
893
463
|
Execute the Collections Delete API to delete multiple records using multithreading.
|
@@ -897,341 +467,97 @@ class SFAuth:
|
|
897
467
|
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
898
468
|
:return: Combined JSON response from all batches or None on complete failure.
|
899
469
|
"""
|
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
|
470
|
+
return self._crud_client.cdelete(ids, batch_size, max_workers)
|
936
471
|
|
937
472
|
def _cupdate(
|
938
|
-
self,
|
473
|
+
self,
|
474
|
+
update_dict: Dict[str, Any],
|
475
|
+
batch_size: int = 25,
|
476
|
+
max_workers: Optional[int] = None,
|
939
477
|
) -> Optional[Dict[str, Any]]:
|
940
478
|
"""
|
941
479
|
Execute the Composite Update API to update multiple records.
|
942
480
|
|
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!'}}
|
481
|
+
: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
482
|
:param batch_size: The number of records to update in each batch (default is 25).
|
483
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
946
484
|
:return: JSON response from the update request or None on failure.
|
947
485
|
"""
|
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:
|
486
|
+
return self._crud_client.cupdate(update_dict, batch_size, max_workers)
|
487
|
+
|
488
|
+
# SOAP methods delegated to SOAP client
|
489
|
+
def _gen_soap_envelope(self, header: str, body: str, api_type: str) -> str:
|
1012
490
|
"""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
|
-
)
|
491
|
+
return self._soap_client.generate_soap_envelope(header, body, api_type)
|
1041
492
|
|
1042
493
|
def _gen_soap_header(self) -> str:
|
1043
494
|
"""This function generates the header for the SOAP request."""
|
1044
|
-
|
1045
|
-
|
1046
|
-
return
|
495
|
+
# Ensure we have a valid token
|
496
|
+
self._refresh_token_if_needed()
|
497
|
+
return self._soap_client.generate_soap_header(self.access_token)
|
1047
498
|
|
1048
499
|
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
|
500
|
+
"""Parse SOAP XML and extract all child fields from <result> as a dict."""
|
501
|
+
return self._soap_client.extract_soap_result_fields(xml_string)
|
1073
502
|
|
1074
503
|
def _gen_soap_body(self, sobject: str, method: str, data: Dict[str, Any]) -> str:
|
1075
504
|
"""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>"
|
505
|
+
return self._soap_client.generate_soap_body(sobject, method, data)
|
1088
506
|
|
1089
507
|
def _xml_to_json(self, xml_string: str) -> Optional[Dict[str, Any]]:
|
1090
|
-
"""
|
1091
|
-
|
508
|
+
"""Convert an XML string to a JSON-like dictionary."""
|
509
|
+
return self._soap_client.xml_to_dict(xml_string)
|
1092
510
|
|
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
|
511
|
+
def _xml_to_dict(self, element) -> Dict[str, Any]:
|
512
|
+
"""Recursively convert an XML Element to a dictionary."""
|
513
|
+
return self._soap_client._xml_element_to_dict(element)
|
1123
514
|
|
1124
515
|
def _create( # I don't like this name, will think of a better one later...as such, not public.
|
1125
516
|
self,
|
1126
517
|
sobject: str,
|
1127
518
|
insert_list: List[Dict[str, Any]],
|
1128
519
|
batch_size: int = 200,
|
1129
|
-
max_workers: int = None,
|
520
|
+
max_workers: Optional[int] = None,
|
1130
521
|
api_type: Literal["enterprise", "tooling"] = "enterprise",
|
1131
522
|
) -> Optional[Dict[str, Any]]:
|
1132
523
|
"""
|
1133
524
|
Execute the Insert API to insert multiple records via SOAP calls.
|
1134
525
|
|
1135
526
|
:param sobject: The name of the sObject to insert into.
|
1136
|
-
:param insert_list: A list of dictionaries, each representing a record to insert.
|
527
|
+
:param insert_list: A list of dictionaries, each representing a record to insert.
|
1137
528
|
:param batch_size: The number of records to insert in each batch (default is 200).
|
1138
529
|
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
530
|
+
:param api_type: API type to use ('enterprise' or 'tooling').
|
1139
531
|
:return: JSON response from the insert request or None on failure.
|
1140
532
|
"""
|
533
|
+
return self._crud_client.create(
|
534
|
+
sobject, insert_list, batch_size, max_workers, api_type
|
535
|
+
)
|
1141
536
|
|
1142
|
-
|
1143
|
-
|
1144
|
-
|
1145
|
-
|
1146
|
-
|
1147
|
-
|
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
|
1208
|
-
|
1209
|
-
def _debug_cleanup_apex_logs(self):
|
1210
|
-
"""
|
1211
|
-
This function performs cleanup operations for Apex debug logs.
|
1212
|
-
"""
|
1213
|
-
apex_logs = self.query("SELECT Id FROM ApexLog ORDER BY LogLength DESC")
|
1214
|
-
if apex_logs and apex_logs.get("records"):
|
1215
|
-
log_ids = [log["Id"] for log in apex_logs["records"]]
|
1216
|
-
if log_ids:
|
1217
|
-
delete_response = self.cdelete(log_ids)
|
1218
|
-
logger.debug("Deleted Apex logs: %s", delete_response)
|
1219
|
-
else:
|
1220
|
-
logger.debug("No Apex logs found to delete.")
|
1221
|
-
|
1222
|
-
def debug_cleanup(self, apex_logs: bool = True) -> None:
|
537
|
+
def debug_cleanup(
|
538
|
+
self,
|
539
|
+
apex_logs: bool = True,
|
540
|
+
expired_apex_flags: bool = True,
|
541
|
+
all_apex_flags: bool = False,
|
542
|
+
) -> None:
|
1223
543
|
"""
|
1224
544
|
Perform cleanup operations for Apex debug logs.
|
1225
545
|
"""
|
1226
|
-
|
1227
|
-
|
546
|
+
self._debug_cleanup.debug_cleanup(
|
547
|
+
apex_logs=apex_logs,
|
548
|
+
expired_apex_flags=expired_apex_flags,
|
549
|
+
all_apex_flags=all_apex_flags,
|
550
|
+
)
|
1228
551
|
|
1229
552
|
def open_frontdoor(self) -> None:
|
1230
553
|
"""
|
1231
554
|
This function opens the Salesforce Frontdoor URL in the default web browser.
|
1232
555
|
"""
|
556
|
+
self._refresh_token_if_needed()
|
1233
557
|
if not self.access_token:
|
1234
|
-
|
558
|
+
logger.error("No access token available for frontdoor URL")
|
559
|
+
return
|
560
|
+
|
1235
561
|
sid = quote(self.access_token, safe="")
|
1236
562
|
frontdoor_url = f"{self.instance_url}/secur/frontdoor.jsp?sid={sid}"
|
1237
563
|
webbrowser.open(frontdoor_url)
|