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 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 xml.etree.ElementTree as ET
15
- from collections import OrderedDict
16
- from concurrent.futures import ThreadPoolExecutor, as_completed
17
- from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
18
- from urllib.parse import quote, urlparse
19
-
20
- __all__ = ["SFAuth"] # https://pdoc.dev/docs/pdoc.html#control-what-is-documented
21
-
22
- TRACE = 5
23
- logging.addLevelName(TRACE, "TRACE")
24
-
25
-
26
- def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
27
- """Custom TRACE level logging function with redaction."""
28
-
29
- def _redact_sensitive(data: Any) -> Any:
30
- """Redacts sensitive keys from a dictionary, query string, or sessionId."""
31
- REDACT_VALUE = "*" * 8
32
- REDACT_KEYS = [
33
- "access_token",
34
- "authorization",
35
- "set-cookie",
36
- "cookie",
37
- "refresh_token",
38
- "client_secret",
39
- "sessionid",
40
- ]
41
- if isinstance(data, dict):
42
- return {
43
- k: (REDACT_VALUE if k.lower() in REDACT_KEYS else v)
44
- for k, v in data.items()
45
- }
46
- elif isinstance(data, (list, tuple)):
47
- return type(data)(
48
- (
49
- (item[0], REDACT_VALUE)
50
- if isinstance(item, tuple) and item[0].lower() in REDACT_KEYS
51
- else item
52
- for item in data
53
- )
54
- )
55
- elif isinstance(data, str):
56
- if "<sessionId>" in data and "</sessionId>" in data:
57
- data = re.sub(
58
- r"(<sessionId>)(.*?)(</sessionId>)",
59
- r"\1{}\3".format(REDACT_VALUE),
60
- data,
61
- )
62
- parts = data.split("&")
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
- refresh_token: str, # client_secret & refresh_token will swap positions 2025-AUG-1
97
- client_secret: str = "_deprecation_warning", # mandatory after 2025-AUG-1
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.32",
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
- self.instance_url = self._format_instance_url(instance_url)
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
- self.client_id = client_id
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
- self.client_secret = client_secret
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
- self.refresh_token = refresh_token
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
- self.__version__ = "0.0.32"
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
- self.token_endpoint = token_endpoint
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.token_expiration_time = token_expiration_time
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
- self._auto_configure_proxy(proxy)
263
- self._high_api_usage_threshold = 80
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
- Prepare the payload for the token request.
291
+ ### `proxy`
309
292
 
310
- This method constructs a dictionary containing the necessary parameters
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
- Returns:
315
- Dict[str, Optional[str]]: A dictionary containing the payload for the token request.
295
+ * Proxy URL for HTTP requests
296
+ * None if no proxy is configured
316
297
  """
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
- }
298
+ return self._auth_manager.get_proxy_config()
323
299
 
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:
300
+ @property
301
+ def org_id(self) -> Optional[str]:
337
302
  """
338
- Create a connection using HTTP or HTTPS, with optional proxy support.
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
- conn.request(method, endpoint, body=body, headers=headers)
382
- response = conn.getresponse()
383
- self._http_resp_header_logic(response)
305
+ **The Salesforce organization ID.**
384
306
 
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]]:
307
+ * Extracted from token response during authentication
308
+ * Available after successful token refresh
399
309
  """
400
- Perform a new token request using the provided payload.
310
+ return self._auth_manager.org_id
401
311
 
402
- :param payload: Payload for the token request.
403
- :return: Parsed JSON response or None on failure.
312
+ @property
313
+ def user_id(self) -> Optional[str]:
404
314
  """
405
- headers = self._get_common_headers(recursive_call=True)
406
- headers["Content-Type"] = "application/x-www-form-urlencoded"
407
- del headers["Authorization"]
315
+ ### `user_id`
408
316
 
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)
317
+ **The Salesforce user ID.**
411
318
 
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
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
- logger.trace(
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
- if self.access_token and not self._is_token_expired():
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
- _safe_resource_name = quote(resource_name, safe="")
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
- endpoint = f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}/Body"
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
- safe_resource_name = quote(resource_name, safe="")
581
- query = f"SELECT Id FROM StaticResource WHERE Name = '{safe_resource_name}'"
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
- payload = {"Body": base64.b64encode(data.encode("utf-8")).decode("utf-8")}
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
- status, data = self._send_request("GET", endpoint, headers)
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
- endpoint = f"/services/data/{self.api_version}/"
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.query(query, tooling=True)
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
- valid_key_types = {"id", "name"}
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, query_dict: dict[str, str], batch_size: int = 25, max_workers: int = None
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
- if not query_dict:
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, ids: Iterable[str], batch_size: int = 200, max_workers: int = None
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 = list(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, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None
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. Example:
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
- allOrNone = False
949
- endpoint = f"/services/data/{self.api_version}/composite"
950
-
951
- compositeRequest_payload = []
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
- if type == "enterprise":
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
- headers = self._get_common_headers()
1045
- session_id = headers["Authorization"].split(" ")[1]
1046
- return f"<soapenv:Header><SessionHeader><sessionId>{session_id}</sessionId></SessionHeader></soapenv:Header>"
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
- Parse SOAP XML and extract all child fields from <result> as a dict.
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
- # Accept both a single dict and a list of dicts
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
- Convert an XML string to a JSON-like dictionary.
504
+ """Convert an XML string to a JSON-like dictionary."""
505
+ return self._soap_client.xml_to_dict(xml_string)
1092
506
 
1093
- :param xml_string: The XML string to convert.
1094
- :return: A dictionary representation of the XML or None on failure.
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. Example: [{'Subject': 'Easily inserted via SFQ'}]
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
- endpoint = "/services/Soap/"
1143
- if api_type == "enterprise":
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
- self._get_common_headers()
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)