sfq 0.0.32__py3-none-any.whl → 0.0.34__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sfq/__init__.py +239 -913
- sfq/_cometd.py +7 -10
- sfq/auth.py +401 -0
- sfq/crud.py +514 -0
- sfq/debug_cleanup.py +71 -0
- sfq/exceptions.py +54 -0
- sfq/http_client.py +319 -0
- sfq/query.py +398 -0
- sfq/soap.py +186 -0
- sfq/utils.py +196 -0
- {sfq-0.0.32.dist-info → sfq-0.0.34.dist-info}/METADATA +1 -1
- sfq-0.0.34.dist-info/RECORD +14 -0
- sfq-0.0.32.dist-info/RECORD +0 -6
- {sfq-0.0.32.dist-info → sfq-0.0.34.dist-info}/WHEEL +0 -0
sfq/crud.py
ADDED
@@ -0,0 +1,514 @@
|
|
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 _soap_batch_operation(
|
41
|
+
self,
|
42
|
+
sobject: str,
|
43
|
+
data_list,
|
44
|
+
method: str,
|
45
|
+
batch_size: int = 200,
|
46
|
+
max_workers: int = None,
|
47
|
+
api_type: Literal["enterprise", "tooling"] = "enterprise",
|
48
|
+
) -> Optional[Dict[str, Any]]:
|
49
|
+
"""
|
50
|
+
Internal helper for batch SOAP operations (create/delete).
|
51
|
+
"""
|
52
|
+
endpoint = "/services/Soap/"
|
53
|
+
if api_type == "enterprise":
|
54
|
+
endpoint += f"c/{self.api_version}"
|
55
|
+
elif api_type == "tooling":
|
56
|
+
endpoint += f"T/{self.api_version}"
|
57
|
+
else:
|
58
|
+
logger.error(
|
59
|
+
"Invalid API type: %s. Must be one of: 'enterprise', 'tooling'.",
|
60
|
+
api_type,
|
61
|
+
)
|
62
|
+
return None
|
63
|
+
|
64
|
+
endpoint = endpoint.replace("/v", "/")
|
65
|
+
|
66
|
+
if isinstance(data_list, dict) and method == "create":
|
67
|
+
data_list = [data_list]
|
68
|
+
if isinstance(data_list, str) and method == "delete":
|
69
|
+
data_list = [data_list]
|
70
|
+
|
71
|
+
chunks = [
|
72
|
+
data_list[i : i + batch_size]
|
73
|
+
for i in range(0, len(data_list), batch_size)
|
74
|
+
]
|
75
|
+
|
76
|
+
def process_chunk(chunk):
|
77
|
+
try:
|
78
|
+
access_token = self.http_client.auth_manager.access_token
|
79
|
+
if not access_token:
|
80
|
+
logger.error("No access token available for SOAP request")
|
81
|
+
return None
|
82
|
+
|
83
|
+
header = self.soap_client.generate_soap_header(access_token)
|
84
|
+
body = self.soap_client.generate_soap_body(
|
85
|
+
sobject=sobject, method=method, data=chunk
|
86
|
+
)
|
87
|
+
envelope = self.soap_client.generate_soap_envelope(
|
88
|
+
header=header, body=body, api_type=api_type
|
89
|
+
)
|
90
|
+
|
91
|
+
soap_headers = self.http_client.get_common_headers().copy()
|
92
|
+
soap_headers["Content-Type"] = "text/xml; charset=UTF-8"
|
93
|
+
soap_headers["SOAPAction"] = '""'
|
94
|
+
|
95
|
+
logger.trace(f"SOAP {method} request envelope: %s", envelope)
|
96
|
+
logger.trace(f"SOAP {method} request headers: %s", soap_headers)
|
97
|
+
|
98
|
+
status_code, resp_data = self.http_client.send_request(
|
99
|
+
method="POST",
|
100
|
+
endpoint=endpoint,
|
101
|
+
headers=soap_headers,
|
102
|
+
body=envelope,
|
103
|
+
)
|
104
|
+
|
105
|
+
logger.trace(f"SOAP {method} response status: {status_code}")
|
106
|
+
logger.trace(f"SOAP {method} raw response: {resp_data}")
|
107
|
+
|
108
|
+
if status_code == 200:
|
109
|
+
logger.debug(f"{method.capitalize()} API request successful.")
|
110
|
+
logger.trace(f"{method.capitalize()} API response: %s", resp_data)
|
111
|
+
result = self.soap_client.extract_soap_result_fields(resp_data)
|
112
|
+
if result:
|
113
|
+
return result
|
114
|
+
logger.error("Failed to extract fields from SOAP response.")
|
115
|
+
else:
|
116
|
+
logger.error(f"{method.capitalize()} API request failed: %s", status_code)
|
117
|
+
logger.debug("Response body: %s", resp_data)
|
118
|
+
return None
|
119
|
+
except Exception as e:
|
120
|
+
logger.exception(f"Exception during {method} chunk: %s", e)
|
121
|
+
return None
|
122
|
+
|
123
|
+
results = []
|
124
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
125
|
+
futures = [executor.submit(process_chunk, chunk) for chunk in chunks]
|
126
|
+
for future in as_completed(futures):
|
127
|
+
result = future.result()
|
128
|
+
if result:
|
129
|
+
results.append(result)
|
130
|
+
|
131
|
+
combined_response = [
|
132
|
+
item
|
133
|
+
for result in results
|
134
|
+
for item in (result if isinstance(result, list) else [result])
|
135
|
+
if isinstance(result, (dict, list))
|
136
|
+
]
|
137
|
+
|
138
|
+
return combined_response or None
|
139
|
+
|
140
|
+
def create(
|
141
|
+
self,
|
142
|
+
sobject: str,
|
143
|
+
insert_list: List[Dict[str, Any]],
|
144
|
+
batch_size: int = 200,
|
145
|
+
max_workers: int = None,
|
146
|
+
api_type: Literal["enterprise", "tooling"] = "enterprise",
|
147
|
+
) -> Optional[Dict[str, Any]]:
|
148
|
+
"""
|
149
|
+
Execute the Insert API to insert multiple records via SOAP calls.
|
150
|
+
"""
|
151
|
+
return self._soap_batch_operation(
|
152
|
+
sobject=sobject,
|
153
|
+
data_list=insert_list,
|
154
|
+
method="create",
|
155
|
+
batch_size=batch_size,
|
156
|
+
max_workers=max_workers,
|
157
|
+
api_type=api_type,
|
158
|
+
)
|
159
|
+
|
160
|
+
def update(
|
161
|
+
self,
|
162
|
+
sobject: str,
|
163
|
+
update_list: List[Dict[str, Any]],
|
164
|
+
batch_size: int = 200,
|
165
|
+
max_workers: int = None,
|
166
|
+
api_type: Literal["enterprise", "tooling"] = "enterprise",
|
167
|
+
) -> Optional[Dict[str, Any]]:
|
168
|
+
"""
|
169
|
+
Execute the Update API to update multiple records via SOAP calls.
|
170
|
+
:param sobject: The name of the sObject to update.
|
171
|
+
:param update_list: A list of dictionaries, each representing a record to update (must include Id).
|
172
|
+
:param batch_size: The number of records to update in each batch (default is 200).
|
173
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution.
|
174
|
+
:param api_type: API type to use ('enterprise' or 'tooling').
|
175
|
+
:return: JSON response from the update request or None on failure.
|
176
|
+
"""
|
177
|
+
return self._soap_batch_operation(
|
178
|
+
sobject=sobject,
|
179
|
+
data_list=update_list,
|
180
|
+
method="update",
|
181
|
+
batch_size=batch_size,
|
182
|
+
max_workers=max_workers,
|
183
|
+
api_type=api_type,
|
184
|
+
)
|
185
|
+
|
186
|
+
def delete(
|
187
|
+
self,
|
188
|
+
sobject: str,
|
189
|
+
id_list: List[str],
|
190
|
+
batch_size: int = 200,
|
191
|
+
max_workers: int = None,
|
192
|
+
api_type: Literal["enterprise", "tooling"] = "enterprise",
|
193
|
+
) -> Optional[Dict[str, Any]]:
|
194
|
+
"""
|
195
|
+
Execute the Delete API to remove multiple records via SOAP calls.
|
196
|
+
:param sobject: The name of the sObject to delete from.
|
197
|
+
:param id_list: A list of record IDs to delete (strings, not dicts).
|
198
|
+
:param batch_size: The number of records to delete in each batch (default is 200).
|
199
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution.
|
200
|
+
:param api_type: API type to use ('enterprise' or 'tooling').
|
201
|
+
:return: JSON response from the delete request or None on failure.
|
202
|
+
"""
|
203
|
+
# Pass list of IDs directly for SOAP delete
|
204
|
+
return self._soap_batch_operation(
|
205
|
+
sobject=sobject,
|
206
|
+
data_list=id_list,
|
207
|
+
method="delete",
|
208
|
+
batch_size=batch_size,
|
209
|
+
max_workers=max_workers,
|
210
|
+
api_type=api_type,
|
211
|
+
)
|
212
|
+
|
213
|
+
def cupdate(
|
214
|
+
self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None
|
215
|
+
) -> Optional[Dict[str, Any]]:
|
216
|
+
"""
|
217
|
+
Execute the Composite Update API to update multiple records.
|
218
|
+
|
219
|
+
:param update_dict: A dictionary of keys of records to be updated, and a dictionary
|
220
|
+
of field-value pairs to be updated, with a special key '_' overriding
|
221
|
+
the sObject type which is otherwise inferred from the key.
|
222
|
+
:param batch_size: The number of records to update in each batch (default is 25).
|
223
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution.
|
224
|
+
:return: JSON response from the update request or None on failure.
|
225
|
+
"""
|
226
|
+
allOrNone = False
|
227
|
+
endpoint = f"/services/data/{self.api_version}/composite"
|
228
|
+
|
229
|
+
compositeRequest_payload = []
|
230
|
+
sobject_prefixes = {}
|
231
|
+
|
232
|
+
for key, record in update_dict.items():
|
233
|
+
record_copy = record.copy()
|
234
|
+
sobject = record_copy.pop("_", None)
|
235
|
+
if not sobject and not sobject_prefixes:
|
236
|
+
# Get sObject prefixes from query client if available
|
237
|
+
# For now, we'll require the sobject to be specified or use key prefix
|
238
|
+
sobject_prefixes = self._get_sobject_prefixes()
|
239
|
+
|
240
|
+
if not sobject:
|
241
|
+
sobject = str(sobject_prefixes.get(str(key[:3]), None))
|
242
|
+
|
243
|
+
compositeRequest_payload.append(
|
244
|
+
{
|
245
|
+
"method": "PATCH",
|
246
|
+
"url": f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
|
247
|
+
"referenceId": key,
|
248
|
+
"body": record_copy,
|
249
|
+
}
|
250
|
+
)
|
251
|
+
|
252
|
+
chunks = [
|
253
|
+
compositeRequest_payload[i : i + batch_size]
|
254
|
+
for i in range(0, len(compositeRequest_payload), batch_size)
|
255
|
+
]
|
256
|
+
|
257
|
+
def update_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
258
|
+
"""Update a chunk of records using Composite API."""
|
259
|
+
try:
|
260
|
+
payload = {"allOrNone": bool(allOrNone), "compositeRequest": chunk}
|
261
|
+
|
262
|
+
status_code, resp_data = self.http_client.send_request(
|
263
|
+
method="POST",
|
264
|
+
endpoint=endpoint,
|
265
|
+
headers=self.http_client.get_common_headers(),
|
266
|
+
body=json.dumps(payload),
|
267
|
+
)
|
268
|
+
|
269
|
+
if status_code == 200:
|
270
|
+
logger.debug("Composite update API response without errors.")
|
271
|
+
return json.loads(resp_data)
|
272
|
+
else:
|
273
|
+
logger.error("Composite update API request failed: %s", status_code)
|
274
|
+
logger.debug("Response body: %s", resp_data)
|
275
|
+
return None
|
276
|
+
|
277
|
+
except Exception as e:
|
278
|
+
logger.exception("Exception during update chunk: %s", e)
|
279
|
+
return None
|
280
|
+
|
281
|
+
results = []
|
282
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
283
|
+
futures = [executor.submit(update_chunk, chunk) for chunk in chunks]
|
284
|
+
for future in as_completed(futures):
|
285
|
+
result = future.result()
|
286
|
+
if result:
|
287
|
+
results.append(result)
|
288
|
+
|
289
|
+
combined_response = [
|
290
|
+
item
|
291
|
+
for result in results
|
292
|
+
for item in (result if isinstance(result, list) else [result])
|
293
|
+
if isinstance(result, (dict, list))
|
294
|
+
]
|
295
|
+
|
296
|
+
return combined_response or None
|
297
|
+
|
298
|
+
def cdelete(
|
299
|
+
self, ids: Iterable[str], batch_size: int = 200, max_workers: int = None
|
300
|
+
) -> Optional[Dict[str, Any]]:
|
301
|
+
"""
|
302
|
+
Execute the Collections Delete API to delete multiple records using multithreading.
|
303
|
+
|
304
|
+
:param ids: A list of record IDs to delete.
|
305
|
+
:param batch_size: The number of records to delete in each batch (default is 200).
|
306
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution.
|
307
|
+
:return: Combined JSON response from all batches or None on complete failure.
|
308
|
+
"""
|
309
|
+
ids = list(ids)
|
310
|
+
chunks = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)]
|
311
|
+
|
312
|
+
def delete_chunk(chunk: List[str]) -> Optional[Dict[str, Any]]:
|
313
|
+
"""Delete a chunk of records using Collections API."""
|
314
|
+
try:
|
315
|
+
endpoint = f"/services/data/{self.api_version}/composite/sobjects?ids={','.join(chunk)}&allOrNone=false"
|
316
|
+
headers = self.http_client.get_common_headers()
|
317
|
+
|
318
|
+
status_code, resp_data = self.http_client.send_request(
|
319
|
+
method="DELETE",
|
320
|
+
endpoint=endpoint,
|
321
|
+
headers=headers,
|
322
|
+
)
|
323
|
+
|
324
|
+
if status_code == 200:
|
325
|
+
logger.debug("Collections delete API response without errors.")
|
326
|
+
return json.loads(resp_data)
|
327
|
+
else:
|
328
|
+
logger.error(
|
329
|
+
"Collections delete API request failed: %s", status_code
|
330
|
+
)
|
331
|
+
logger.debug("Response body: %s", resp_data)
|
332
|
+
return None
|
333
|
+
|
334
|
+
except Exception as e:
|
335
|
+
logger.exception("Exception during delete chunk: %s", e)
|
336
|
+
return None
|
337
|
+
|
338
|
+
results = []
|
339
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
340
|
+
futures = [executor.submit(delete_chunk, chunk) for chunk in chunks]
|
341
|
+
for future in as_completed(futures):
|
342
|
+
result = future.result()
|
343
|
+
if result:
|
344
|
+
results.append(result)
|
345
|
+
|
346
|
+
combined_response = [
|
347
|
+
item
|
348
|
+
for result in results
|
349
|
+
for item in (result if isinstance(result, list) else [result])
|
350
|
+
if isinstance(result, (dict, list))
|
351
|
+
]
|
352
|
+
return combined_response or None
|
353
|
+
|
354
|
+
def read_static_resource_name(
|
355
|
+
self, resource_name: str, namespace: Optional[str] = None
|
356
|
+
) -> Optional[str]:
|
357
|
+
"""
|
358
|
+
Read a static resource for a given name from the Salesforce instance.
|
359
|
+
|
360
|
+
:param resource_name: Name of the static resource to read.
|
361
|
+
:param namespace: Namespace of the static resource to read (default is None).
|
362
|
+
:return: Static resource content or None on failure.
|
363
|
+
"""
|
364
|
+
_safe_resource_name = quote(resource_name, safe="")
|
365
|
+
query = f"SELECT Id FROM StaticResource WHERE Name = '{_safe_resource_name}'"
|
366
|
+
if namespace:
|
367
|
+
namespace = quote(namespace, safe="")
|
368
|
+
query += f" AND NamespacePrefix = '{namespace}'"
|
369
|
+
query += " LIMIT 1"
|
370
|
+
|
371
|
+
# Make the query directly via HTTP client
|
372
|
+
query_endpoint = f"/services/data/{self.api_version}/query?q={quote(query)}"
|
373
|
+
status_code, response_data = self.http_client.send_authenticated_request(
|
374
|
+
method="GET",
|
375
|
+
endpoint=query_endpoint,
|
376
|
+
)
|
377
|
+
|
378
|
+
if status_code == 200:
|
379
|
+
_static_resource_id_response = json.loads(response_data)
|
380
|
+
else:
|
381
|
+
logger.error("Failed to query for static resource: %s", status_code)
|
382
|
+
_static_resource_id_response = None
|
383
|
+
|
384
|
+
if (
|
385
|
+
_static_resource_id_response
|
386
|
+
and _static_resource_id_response.get("records")
|
387
|
+
and len(_static_resource_id_response["records"]) > 0
|
388
|
+
):
|
389
|
+
return self.read_static_resource_id(
|
390
|
+
_static_resource_id_response["records"][0].get("Id")
|
391
|
+
)
|
392
|
+
|
393
|
+
logger.error(f"Failed to read static resource with name {_safe_resource_name}.")
|
394
|
+
return None
|
395
|
+
|
396
|
+
def read_static_resource_id(self, resource_id: str) -> Optional[str]:
|
397
|
+
"""
|
398
|
+
Read a static resource for a given ID from the Salesforce instance.
|
399
|
+
|
400
|
+
:param resource_id: ID of the static resource to read.
|
401
|
+
:return: Static resource content or None on failure.
|
402
|
+
"""
|
403
|
+
endpoint = f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}/Body"
|
404
|
+
|
405
|
+
# Use a special method for binary content
|
406
|
+
status, data = self.http_client.send_authenticated_request("GET", endpoint)
|
407
|
+
|
408
|
+
if status == 200:
|
409
|
+
logger.debug("Static resource fetched successfully.")
|
410
|
+
# Try to decode as UTF-8, but handle binary content gracefully
|
411
|
+
try:
|
412
|
+
return data.decode("utf-8") if isinstance(data, bytes) else data
|
413
|
+
except UnicodeDecodeError:
|
414
|
+
# For binary content, return base64 encoded string
|
415
|
+
import base64
|
416
|
+
|
417
|
+
return (
|
418
|
+
base64.b64encode(data).decode("utf-8")
|
419
|
+
if isinstance(data, bytes)
|
420
|
+
else data
|
421
|
+
)
|
422
|
+
|
423
|
+
logger.error("Failed to fetch static resource: %s", status)
|
424
|
+
return None
|
425
|
+
|
426
|
+
def update_static_resource_name(
|
427
|
+
self, resource_name: str, data: str, namespace: Optional[str] = None
|
428
|
+
) -> Optional[Dict[str, Any]]:
|
429
|
+
"""
|
430
|
+
Update a static resource for a given name in the Salesforce instance.
|
431
|
+
|
432
|
+
:param resource_name: Name of the static resource to update.
|
433
|
+
:param data: Content to update the static resource with.
|
434
|
+
:param namespace: Optional namespace to search for the static resource.
|
435
|
+
:return: Static resource content or None on failure.
|
436
|
+
"""
|
437
|
+
safe_resource_name = quote(resource_name, safe="")
|
438
|
+
query = f"SELECT Id FROM StaticResource WHERE Name = '{safe_resource_name}'"
|
439
|
+
if namespace:
|
440
|
+
namespace = quote(namespace, safe="")
|
441
|
+
query += f" AND NamespacePrefix = '{namespace}'"
|
442
|
+
query += " LIMIT 1"
|
443
|
+
|
444
|
+
# We need to use the HTTP client to make a query request
|
445
|
+
query_endpoint = f"/services/data/{self.api_version}/query?q={quote(query)}"
|
446
|
+
status_code, response_data = self.http_client.send_authenticated_request(
|
447
|
+
method="GET",
|
448
|
+
endpoint=query_endpoint,
|
449
|
+
)
|
450
|
+
|
451
|
+
if status_code == 200:
|
452
|
+
static_resource_id_response = json.loads(response_data)
|
453
|
+
else:
|
454
|
+
logger.error("Failed to query for static resource: %s", status_code)
|
455
|
+
static_resource_id_response = None
|
456
|
+
|
457
|
+
if (
|
458
|
+
static_resource_id_response
|
459
|
+
and static_resource_id_response.get("records")
|
460
|
+
and len(static_resource_id_response["records"]) > 0
|
461
|
+
):
|
462
|
+
return self.update_static_resource_id(
|
463
|
+
static_resource_id_response["records"][0].get("Id"), data
|
464
|
+
)
|
465
|
+
|
466
|
+
logger.error(
|
467
|
+
f"Failed to update static resource with name {safe_resource_name}."
|
468
|
+
)
|
469
|
+
return None
|
470
|
+
|
471
|
+
def update_static_resource_id(
|
472
|
+
self, resource_id: str, data: str
|
473
|
+
) -> Optional[Dict[str, Any]]:
|
474
|
+
"""
|
475
|
+
Replace the content of a static resource in the Salesforce instance by ID.
|
476
|
+
|
477
|
+
:param resource_id: ID of the static resource to update.
|
478
|
+
:param data: Content to update the static resource with.
|
479
|
+
:return: Parsed JSON response or None on failure.
|
480
|
+
"""
|
481
|
+
payload = {"Body": base64.b64encode(data.encode("utf-8")).decode("utf-8")}
|
482
|
+
|
483
|
+
endpoint = (
|
484
|
+
f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
|
485
|
+
)
|
486
|
+
status_code, response_data = self.http_client.send_authenticated_request(
|
487
|
+
method="PATCH",
|
488
|
+
endpoint=endpoint,
|
489
|
+
body=json.dumps(payload),
|
490
|
+
)
|
491
|
+
|
492
|
+
if status_code in [200, 204]: # 204 No Content is also success for PATCH
|
493
|
+
logger.debug("Patch Static Resource request successful.")
|
494
|
+
if response_data and response_data.strip():
|
495
|
+
return json.loads(response_data)
|
496
|
+
else:
|
497
|
+
return {"success": True} # Return success indicator for 204 responses
|
498
|
+
|
499
|
+
logger.error(
|
500
|
+
"Patch Static Resource API request failed: %s",
|
501
|
+
status_code,
|
502
|
+
)
|
503
|
+
logger.error("Response body: %s", response_data)
|
504
|
+
|
505
|
+
return None
|
506
|
+
|
507
|
+
def _get_sobject_prefixes(self) -> Dict[str, str]:
|
508
|
+
"""
|
509
|
+
Get sObject prefixes mapping. This is a placeholder that would normally
|
510
|
+
call the query client to get prefixes.
|
511
|
+
|
512
|
+
:return: Dictionary mapping key prefixes to sObject names
|
513
|
+
"""
|
514
|
+
return QueryClient.get_sobject_prefixes(self)
|
sfq/debug_cleanup.py
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
"""
|
2
|
+
Debug cleanup module for Salesforce-related debug artifacts.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from datetime import datetime, timezone
|
6
|
+
import logging
|
7
|
+
from typing import Dict, Any, Optional
|
8
|
+
|
9
|
+
logger = logging.getLogger("sfq")
|
10
|
+
|
11
|
+
|
12
|
+
class DebugCleanup:
|
13
|
+
"""
|
14
|
+
Class handling debug artifact cleanup operations in Salesforce.
|
15
|
+
"""
|
16
|
+
|
17
|
+
def __init__(self, sf_auth):
|
18
|
+
"""
|
19
|
+
Initialize the DebugCleanup with an SFAuth instance.
|
20
|
+
|
21
|
+
:param sf_auth: An authenticated SFAuth instance
|
22
|
+
"""
|
23
|
+
self._sf_auth = sf_auth
|
24
|
+
|
25
|
+
def _debug_cleanup_apex_logs(self) -> None:
|
26
|
+
"""
|
27
|
+
This function performs cleanup operations for Apex debug logs.
|
28
|
+
"""
|
29
|
+
apex_logs = self._sf_auth.query("SELECT Id FROM ApexLog ORDER BY LogLength DESC")
|
30
|
+
if apex_logs and apex_logs.get("records"):
|
31
|
+
log_ids = [log["Id"] for log in apex_logs["records"]]
|
32
|
+
if log_ids:
|
33
|
+
delete_response = self._sf_auth.cdelete(log_ids)
|
34
|
+
logger.debug("Deleted Apex logs: %s", delete_response)
|
35
|
+
else:
|
36
|
+
logger.debug("No Apex logs found to delete.")
|
37
|
+
|
38
|
+
def _debug_cleanup_trace_flags(self, expired_only: bool) -> None:
|
39
|
+
"""
|
40
|
+
This function performs cleanup operations for Trace Flags.
|
41
|
+
"""
|
42
|
+
if expired_only:
|
43
|
+
now_iso_format = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
44
|
+
query = f"SELECT Id, ExpirationDate FROM TraceFlag WHERE ExpirationDate < {now_iso_format}"
|
45
|
+
else:
|
46
|
+
query = "SELECT Id, ExpirationDate FROM TraceFlag"
|
47
|
+
|
48
|
+
traceflags = self._sf_auth.tooling_query(query)
|
49
|
+
if traceflags['totalSize'] == 0:
|
50
|
+
logger.debug("No expired Trace Flag configurations to delete.")
|
51
|
+
return
|
52
|
+
|
53
|
+
trace_flag_ids = [tf["Id"] for tf in traceflags["records"]]
|
54
|
+
logger.debug("Deleting Trace Flags: %s", trace_flag_ids)
|
55
|
+
results = self._sf_auth._crud_client.delete("TraceFlag", trace_flag_ids, api_type="tooling")
|
56
|
+
# results = self._sf_auth.delete("TraceFlag", trace_flag_ids)
|
57
|
+
logger.debug("Deleted Trace Flags: %s", results)
|
58
|
+
|
59
|
+
def debug_cleanup(self, apex_logs: bool = True, expired_apex_flags: bool = True, all_apex_flags: bool = False) -> None:
|
60
|
+
"""
|
61
|
+
Perform cleanup operations for Apex debug logs.
|
62
|
+
|
63
|
+
:param apex_logs: Whether to clean up Apex logs (default: True)
|
64
|
+
"""
|
65
|
+
if apex_logs:
|
66
|
+
self._debug_cleanup_apex_logs()
|
67
|
+
|
68
|
+
if all_apex_flags:
|
69
|
+
self._debug_cleanup_trace_flags(expired_only=False)
|
70
|
+
elif expired_apex_flags:
|
71
|
+
self._debug_cleanup_trace_flags(expired_only=True)
|
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
|