sfq 0.0.31__py3-none-any.whl → 0.0.33__py3-none-any.whl

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