sfq 0.0.32__py3-none-any.whl → 0.0.34__py3-none-any.whl

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