sfq 0.0.32__py3-none-any.whl → 0.0.33__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sfq/__init__.py +223 -896
- sfq/_cometd.py +7 -10
- sfq/auth.py +401 -0
- sfq/crud.py +446 -0
- sfq/exceptions.py +54 -0
- sfq/http_client.py +319 -0
- sfq/query.py +398 -0
- sfq/soap.py +181 -0
- sfq/utils.py +196 -0
- {sfq-0.0.32.dist-info → sfq-0.0.33.dist-info}/METADATA +1 -1
- sfq-0.0.33.dist-info/RECORD +13 -0
- sfq-0.0.32.dist-info/RECORD +0 -6
- {sfq-0.0.32.dist-info → sfq-0.0.33.dist-info}/WHEEL +0 -0
sfq/crud.py
ADDED
@@ -0,0 +1,446 @@
|
|
1
|
+
"""
|
2
|
+
CRUD operations module for SFQ library.
|
3
|
+
|
4
|
+
This module provides CRUD (Create, Read, Update, Delete) operations for Salesforce records
|
5
|
+
using various APIs including SOAP, REST, and Composite APIs.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import base64
|
9
|
+
import json
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
11
|
+
from typing import Any, Dict, Iterable, List, Literal, Optional
|
12
|
+
from urllib.parse import quote
|
13
|
+
|
14
|
+
from .query import QueryClient
|
15
|
+
from .utils import get_logger
|
16
|
+
|
17
|
+
logger = get_logger("sfq.crud")
|
18
|
+
|
19
|
+
|
20
|
+
class CRUDClient:
|
21
|
+
"""
|
22
|
+
Client for CRUD operations on Salesforce records.
|
23
|
+
|
24
|
+
This class handles create, update, and delete operations using appropriate
|
25
|
+
Salesforce APIs with batch processing and threading support.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(self, http_client, soap_client, api_version: str = "v64.0"):
|
29
|
+
"""
|
30
|
+
Initialize the CRUD client.
|
31
|
+
|
32
|
+
:param http_client: HTTPClient instance for making HTTP requests
|
33
|
+
:param soap_client: SOAPClient instance for SOAP operations
|
34
|
+
:param api_version: Salesforce API version to use
|
35
|
+
"""
|
36
|
+
self.http_client = http_client
|
37
|
+
self.soap_client = soap_client
|
38
|
+
self.api_version = api_version
|
39
|
+
|
40
|
+
def create(
|
41
|
+
self,
|
42
|
+
sobject: str,
|
43
|
+
insert_list: List[Dict[str, Any]],
|
44
|
+
batch_size: int = 200,
|
45
|
+
max_workers: int = None,
|
46
|
+
api_type: Literal["enterprise", "tooling"] = "enterprise",
|
47
|
+
) -> Optional[Dict[str, Any]]:
|
48
|
+
"""
|
49
|
+
Execute the Insert API to insert multiple records via SOAP calls.
|
50
|
+
|
51
|
+
:param sobject: The name of the sObject to insert into.
|
52
|
+
:param insert_list: A list of dictionaries, each representing a record to insert.
|
53
|
+
:param batch_size: The number of records to insert in each batch (default is 200).
|
54
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution.
|
55
|
+
:param api_type: API type to use ('enterprise' or 'tooling').
|
56
|
+
:return: JSON response from the insert request or None on failure.
|
57
|
+
"""
|
58
|
+
endpoint = "/services/Soap/"
|
59
|
+
if api_type == "enterprise":
|
60
|
+
endpoint += f"c/{self.api_version}"
|
61
|
+
elif api_type == "tooling":
|
62
|
+
endpoint += f"T/{self.api_version}"
|
63
|
+
else:
|
64
|
+
logger.error(
|
65
|
+
"Invalid API type: %s. Must be one of: 'enterprise', 'tooling'.",
|
66
|
+
api_type,
|
67
|
+
)
|
68
|
+
return None
|
69
|
+
|
70
|
+
# Handle API versioning in the endpoint
|
71
|
+
endpoint = endpoint.replace("/v", "/")
|
72
|
+
|
73
|
+
if isinstance(insert_list, dict):
|
74
|
+
insert_list = [insert_list]
|
75
|
+
|
76
|
+
chunks = [
|
77
|
+
insert_list[i : i + batch_size]
|
78
|
+
for i in range(0, len(insert_list), batch_size)
|
79
|
+
]
|
80
|
+
|
81
|
+
def insert_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
82
|
+
"""Insert a chunk of records using SOAP API."""
|
83
|
+
try:
|
84
|
+
# Get the access token for the SOAP header
|
85
|
+
access_token = self.http_client.auth_manager.access_token
|
86
|
+
if not access_token:
|
87
|
+
logger.error("No access token available for SOAP request")
|
88
|
+
return None
|
89
|
+
|
90
|
+
header = self.soap_client.generate_soap_header(access_token)
|
91
|
+
body = self.soap_client.generate_soap_body(
|
92
|
+
sobject=sobject, method="create", data=chunk
|
93
|
+
)
|
94
|
+
envelope = self.soap_client.generate_soap_envelope(
|
95
|
+
header=header, body=body, api_type=api_type
|
96
|
+
)
|
97
|
+
|
98
|
+
soap_headers = self.http_client.get_common_headers().copy()
|
99
|
+
soap_headers["Content-Type"] = "text/xml; charset=UTF-8"
|
100
|
+
soap_headers["SOAPAction"] = '""'
|
101
|
+
|
102
|
+
logger.trace("SOAP request envelope: %s", envelope)
|
103
|
+
logger.trace("SOAP request headers: %s", soap_headers)
|
104
|
+
|
105
|
+
status_code, resp_data = self.http_client.send_request(
|
106
|
+
method="POST",
|
107
|
+
endpoint=endpoint,
|
108
|
+
headers=soap_headers,
|
109
|
+
body=envelope,
|
110
|
+
)
|
111
|
+
|
112
|
+
if status_code == 200:
|
113
|
+
logger.debug("Insert API request successful.")
|
114
|
+
logger.trace("Insert API response: %s", resp_data)
|
115
|
+
result = self.soap_client.extract_soap_result_fields(resp_data)
|
116
|
+
if result:
|
117
|
+
return result
|
118
|
+
logger.error("Failed to extract fields from SOAP response.")
|
119
|
+
else:
|
120
|
+
logger.error("Insert API request failed: %s", status_code)
|
121
|
+
logger.debug("Response body: %s", resp_data)
|
122
|
+
return None
|
123
|
+
|
124
|
+
except Exception as e:
|
125
|
+
logger.exception("Exception during insert chunk: %s", e)
|
126
|
+
return None
|
127
|
+
|
128
|
+
results = []
|
129
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
130
|
+
futures = [executor.submit(insert_chunk, chunk) for chunk in chunks]
|
131
|
+
for future in as_completed(futures):
|
132
|
+
result = future.result()
|
133
|
+
if result:
|
134
|
+
results.append(result)
|
135
|
+
|
136
|
+
combined_response = [
|
137
|
+
item
|
138
|
+
for result in results
|
139
|
+
for item in (result if isinstance(result, list) else [result])
|
140
|
+
if isinstance(result, (dict, list))
|
141
|
+
]
|
142
|
+
|
143
|
+
return combined_response or None
|
144
|
+
|
145
|
+
def cupdate(
|
146
|
+
self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None
|
147
|
+
) -> Optional[Dict[str, Any]]:
|
148
|
+
"""
|
149
|
+
Execute the Composite Update API to update multiple records.
|
150
|
+
|
151
|
+
:param update_dict: A dictionary of keys of records to be updated, and a dictionary
|
152
|
+
of field-value pairs to be updated, with a special key '_' overriding
|
153
|
+
the sObject type which is otherwise inferred from the key.
|
154
|
+
:param batch_size: The number of records to update in each batch (default is 25).
|
155
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution.
|
156
|
+
:return: JSON response from the update request or None on failure.
|
157
|
+
"""
|
158
|
+
allOrNone = False
|
159
|
+
endpoint = f"/services/data/{self.api_version}/composite"
|
160
|
+
|
161
|
+
compositeRequest_payload = []
|
162
|
+
sobject_prefixes = {}
|
163
|
+
|
164
|
+
for key, record in update_dict.items():
|
165
|
+
record_copy = record.copy()
|
166
|
+
sobject = record_copy.pop("_", None)
|
167
|
+
if not sobject and not sobject_prefixes:
|
168
|
+
# Get sObject prefixes from query client if available
|
169
|
+
# For now, we'll require the sobject to be specified or use key prefix
|
170
|
+
sobject_prefixes = self._get_sobject_prefixes()
|
171
|
+
|
172
|
+
if not sobject:
|
173
|
+
sobject = str(sobject_prefixes.get(str(key[:3]), None))
|
174
|
+
|
175
|
+
compositeRequest_payload.append(
|
176
|
+
{
|
177
|
+
"method": "PATCH",
|
178
|
+
"url": f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
|
179
|
+
"referenceId": key,
|
180
|
+
"body": record_copy,
|
181
|
+
}
|
182
|
+
)
|
183
|
+
|
184
|
+
chunks = [
|
185
|
+
compositeRequest_payload[i : i + batch_size]
|
186
|
+
for i in range(0, len(compositeRequest_payload), batch_size)
|
187
|
+
]
|
188
|
+
|
189
|
+
def update_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
190
|
+
"""Update a chunk of records using Composite API."""
|
191
|
+
try:
|
192
|
+
payload = {"allOrNone": bool(allOrNone), "compositeRequest": chunk}
|
193
|
+
|
194
|
+
status_code, resp_data = self.http_client.send_request(
|
195
|
+
method="POST",
|
196
|
+
endpoint=endpoint,
|
197
|
+
headers=self.http_client.get_common_headers(),
|
198
|
+
body=json.dumps(payload),
|
199
|
+
)
|
200
|
+
|
201
|
+
if status_code == 200:
|
202
|
+
logger.debug("Composite update API response without errors.")
|
203
|
+
return json.loads(resp_data)
|
204
|
+
else:
|
205
|
+
logger.error("Composite update API request failed: %s", status_code)
|
206
|
+
logger.debug("Response body: %s", resp_data)
|
207
|
+
return None
|
208
|
+
|
209
|
+
except Exception as e:
|
210
|
+
logger.exception("Exception during update chunk: %s", e)
|
211
|
+
return None
|
212
|
+
|
213
|
+
results = []
|
214
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
215
|
+
futures = [executor.submit(update_chunk, chunk) for chunk in chunks]
|
216
|
+
for future in as_completed(futures):
|
217
|
+
result = future.result()
|
218
|
+
if result:
|
219
|
+
results.append(result)
|
220
|
+
|
221
|
+
combined_response = [
|
222
|
+
item
|
223
|
+
for result in results
|
224
|
+
for item in (result if isinstance(result, list) else [result])
|
225
|
+
if isinstance(result, (dict, list))
|
226
|
+
]
|
227
|
+
|
228
|
+
return combined_response or None
|
229
|
+
|
230
|
+
def cdelete(
|
231
|
+
self, ids: Iterable[str], batch_size: int = 200, max_workers: int = None
|
232
|
+
) -> Optional[Dict[str, Any]]:
|
233
|
+
"""
|
234
|
+
Execute the Collections Delete API to delete multiple records using multithreading.
|
235
|
+
|
236
|
+
:param ids: A list of record IDs to delete.
|
237
|
+
:param batch_size: The number of records to delete in each batch (default is 200).
|
238
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution.
|
239
|
+
:return: Combined JSON response from all batches or None on complete failure.
|
240
|
+
"""
|
241
|
+
ids = list(ids)
|
242
|
+
chunks = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)]
|
243
|
+
|
244
|
+
def delete_chunk(chunk: List[str]) -> Optional[Dict[str, Any]]:
|
245
|
+
"""Delete a chunk of records using Collections API."""
|
246
|
+
try:
|
247
|
+
endpoint = f"/services/data/{self.api_version}/composite/sobjects?ids={','.join(chunk)}&allOrNone=false"
|
248
|
+
headers = self.http_client.get_common_headers()
|
249
|
+
|
250
|
+
status_code, resp_data = self.http_client.send_request(
|
251
|
+
method="DELETE",
|
252
|
+
endpoint=endpoint,
|
253
|
+
headers=headers,
|
254
|
+
)
|
255
|
+
|
256
|
+
if status_code == 200:
|
257
|
+
logger.debug("Collections delete API response without errors.")
|
258
|
+
return json.loads(resp_data)
|
259
|
+
else:
|
260
|
+
logger.error(
|
261
|
+
"Collections delete API request failed: %s", status_code
|
262
|
+
)
|
263
|
+
logger.debug("Response body: %s", resp_data)
|
264
|
+
return None
|
265
|
+
|
266
|
+
except Exception as e:
|
267
|
+
logger.exception("Exception during delete chunk: %s", e)
|
268
|
+
return None
|
269
|
+
|
270
|
+
results = []
|
271
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
272
|
+
futures = [executor.submit(delete_chunk, chunk) for chunk in chunks]
|
273
|
+
for future in as_completed(futures):
|
274
|
+
result = future.result()
|
275
|
+
if result:
|
276
|
+
results.append(result)
|
277
|
+
|
278
|
+
combined_response = [
|
279
|
+
item
|
280
|
+
for result in results
|
281
|
+
for item in (result if isinstance(result, list) else [result])
|
282
|
+
if isinstance(result, (dict, list))
|
283
|
+
]
|
284
|
+
return combined_response or None
|
285
|
+
|
286
|
+
def read_static_resource_name(
|
287
|
+
self, resource_name: str, namespace: Optional[str] = None
|
288
|
+
) -> Optional[str]:
|
289
|
+
"""
|
290
|
+
Read a static resource for a given name from the Salesforce instance.
|
291
|
+
|
292
|
+
:param resource_name: Name of the static resource to read.
|
293
|
+
:param namespace: Namespace of the static resource to read (default is None).
|
294
|
+
:return: Static resource content or None on failure.
|
295
|
+
"""
|
296
|
+
_safe_resource_name = quote(resource_name, safe="")
|
297
|
+
query = f"SELECT Id FROM StaticResource WHERE Name = '{_safe_resource_name}'"
|
298
|
+
if namespace:
|
299
|
+
namespace = quote(namespace, safe="")
|
300
|
+
query += f" AND NamespacePrefix = '{namespace}'"
|
301
|
+
query += " LIMIT 1"
|
302
|
+
|
303
|
+
# Make the query directly via HTTP client
|
304
|
+
query_endpoint = f"/services/data/{self.api_version}/query?q={quote(query)}"
|
305
|
+
status_code, response_data = self.http_client.send_authenticated_request(
|
306
|
+
method="GET",
|
307
|
+
endpoint=query_endpoint,
|
308
|
+
)
|
309
|
+
|
310
|
+
if status_code == 200:
|
311
|
+
_static_resource_id_response = json.loads(response_data)
|
312
|
+
else:
|
313
|
+
logger.error("Failed to query for static resource: %s", status_code)
|
314
|
+
_static_resource_id_response = None
|
315
|
+
|
316
|
+
if (
|
317
|
+
_static_resource_id_response
|
318
|
+
and _static_resource_id_response.get("records")
|
319
|
+
and len(_static_resource_id_response["records"]) > 0
|
320
|
+
):
|
321
|
+
return self.read_static_resource_id(
|
322
|
+
_static_resource_id_response["records"][0].get("Id")
|
323
|
+
)
|
324
|
+
|
325
|
+
logger.error(f"Failed to read static resource with name {_safe_resource_name}.")
|
326
|
+
return None
|
327
|
+
|
328
|
+
def read_static_resource_id(self, resource_id: str) -> Optional[str]:
|
329
|
+
"""
|
330
|
+
Read a static resource for a given ID from the Salesforce instance.
|
331
|
+
|
332
|
+
:param resource_id: ID of the static resource to read.
|
333
|
+
:return: Static resource content or None on failure.
|
334
|
+
"""
|
335
|
+
endpoint = f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}/Body"
|
336
|
+
|
337
|
+
# Use a special method for binary content
|
338
|
+
status, data = self.http_client.send_authenticated_request("GET", endpoint)
|
339
|
+
|
340
|
+
if status == 200:
|
341
|
+
logger.debug("Static resource fetched successfully.")
|
342
|
+
# Try to decode as UTF-8, but handle binary content gracefully
|
343
|
+
try:
|
344
|
+
return data.decode("utf-8") if isinstance(data, bytes) else data
|
345
|
+
except UnicodeDecodeError:
|
346
|
+
# For binary content, return base64 encoded string
|
347
|
+
import base64
|
348
|
+
|
349
|
+
return (
|
350
|
+
base64.b64encode(data).decode("utf-8")
|
351
|
+
if isinstance(data, bytes)
|
352
|
+
else data
|
353
|
+
)
|
354
|
+
|
355
|
+
logger.error("Failed to fetch static resource: %s", status)
|
356
|
+
return None
|
357
|
+
|
358
|
+
def update_static_resource_name(
|
359
|
+
self, resource_name: str, data: str, namespace: Optional[str] = None
|
360
|
+
) -> Optional[Dict[str, Any]]:
|
361
|
+
"""
|
362
|
+
Update a static resource for a given name in the Salesforce instance.
|
363
|
+
|
364
|
+
:param resource_name: Name of the static resource to update.
|
365
|
+
:param data: Content to update the static resource with.
|
366
|
+
:param namespace: Optional namespace to search for the static resource.
|
367
|
+
:return: Static resource content or None on failure.
|
368
|
+
"""
|
369
|
+
safe_resource_name = quote(resource_name, safe="")
|
370
|
+
query = f"SELECT Id FROM StaticResource WHERE Name = '{safe_resource_name}'"
|
371
|
+
if namespace:
|
372
|
+
namespace = quote(namespace, safe="")
|
373
|
+
query += f" AND NamespacePrefix = '{namespace}'"
|
374
|
+
query += " LIMIT 1"
|
375
|
+
|
376
|
+
# We need to use the HTTP client to make a query request
|
377
|
+
query_endpoint = f"/services/data/{self.api_version}/query?q={quote(query)}"
|
378
|
+
status_code, response_data = self.http_client.send_authenticated_request(
|
379
|
+
method="GET",
|
380
|
+
endpoint=query_endpoint,
|
381
|
+
)
|
382
|
+
|
383
|
+
if status_code == 200:
|
384
|
+
static_resource_id_response = json.loads(response_data)
|
385
|
+
else:
|
386
|
+
logger.error("Failed to query for static resource: %s", status_code)
|
387
|
+
static_resource_id_response = None
|
388
|
+
|
389
|
+
if (
|
390
|
+
static_resource_id_response
|
391
|
+
and static_resource_id_response.get("records")
|
392
|
+
and len(static_resource_id_response["records"]) > 0
|
393
|
+
):
|
394
|
+
return self.update_static_resource_id(
|
395
|
+
static_resource_id_response["records"][0].get("Id"), data
|
396
|
+
)
|
397
|
+
|
398
|
+
logger.error(
|
399
|
+
f"Failed to update static resource with name {safe_resource_name}."
|
400
|
+
)
|
401
|
+
return None
|
402
|
+
|
403
|
+
def update_static_resource_id(
|
404
|
+
self, resource_id: str, data: str
|
405
|
+
) -> Optional[Dict[str, Any]]:
|
406
|
+
"""
|
407
|
+
Replace the content of a static resource in the Salesforce instance by ID.
|
408
|
+
|
409
|
+
:param resource_id: ID of the static resource to update.
|
410
|
+
:param data: Content to update the static resource with.
|
411
|
+
:return: Parsed JSON response or None on failure.
|
412
|
+
"""
|
413
|
+
payload = {"Body": base64.b64encode(data.encode("utf-8")).decode("utf-8")}
|
414
|
+
|
415
|
+
endpoint = (
|
416
|
+
f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
|
417
|
+
)
|
418
|
+
status_code, response_data = self.http_client.send_authenticated_request(
|
419
|
+
method="PATCH",
|
420
|
+
endpoint=endpoint,
|
421
|
+
body=json.dumps(payload),
|
422
|
+
)
|
423
|
+
|
424
|
+
if status_code in [200, 204]: # 204 No Content is also success for PATCH
|
425
|
+
logger.debug("Patch Static Resource request successful.")
|
426
|
+
if response_data and response_data.strip():
|
427
|
+
return json.loads(response_data)
|
428
|
+
else:
|
429
|
+
return {"success": True} # Return success indicator for 204 responses
|
430
|
+
|
431
|
+
logger.error(
|
432
|
+
"Patch Static Resource API request failed: %s",
|
433
|
+
status_code,
|
434
|
+
)
|
435
|
+
logger.error("Response body: %s", response_data)
|
436
|
+
|
437
|
+
return None
|
438
|
+
|
439
|
+
def _get_sobject_prefixes(self) -> Dict[str, str]:
|
440
|
+
"""
|
441
|
+
Get sObject prefixes mapping. This is a placeholder that would normally
|
442
|
+
call the query client to get prefixes.
|
443
|
+
|
444
|
+
:return: Dictionary mapping key prefixes to sObject names
|
445
|
+
"""
|
446
|
+
return QueryClient.get_sobject_prefixes(self)
|
sfq/exceptions.py
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
"""
|
2
|
+
Custom exception classes for the SFQ library.
|
3
|
+
|
4
|
+
This module defines the exception hierarchy used throughout the SFQ library
|
5
|
+
to provide consistent error handling and meaningful error messages.
|
6
|
+
"""
|
7
|
+
|
8
|
+
|
9
|
+
class SFQException(Exception):
|
10
|
+
"""Base exception for SFQ library."""
|
11
|
+
|
12
|
+
pass
|
13
|
+
|
14
|
+
|
15
|
+
class AuthenticationError(SFQException):
|
16
|
+
"""Raised when authentication fails."""
|
17
|
+
|
18
|
+
pass
|
19
|
+
|
20
|
+
|
21
|
+
class APIError(SFQException):
|
22
|
+
"""Raised when API requests fail."""
|
23
|
+
|
24
|
+
pass
|
25
|
+
|
26
|
+
|
27
|
+
class QueryError(APIError):
|
28
|
+
"""Raised when query operations fail."""
|
29
|
+
|
30
|
+
pass
|
31
|
+
|
32
|
+
|
33
|
+
class CRUDError(APIError):
|
34
|
+
"""Raised when CRUD operations fail."""
|
35
|
+
|
36
|
+
pass
|
37
|
+
|
38
|
+
|
39
|
+
class SOAPError(APIError):
|
40
|
+
"""Raised when SOAP operations fail."""
|
41
|
+
|
42
|
+
pass
|
43
|
+
|
44
|
+
|
45
|
+
class HTTPError(SFQException):
|
46
|
+
"""Raised when HTTP communication fails."""
|
47
|
+
|
48
|
+
pass
|
49
|
+
|
50
|
+
|
51
|
+
class ConfigurationError(SFQException):
|
52
|
+
"""Raised when configuration is invalid."""
|
53
|
+
|
54
|
+
pass
|