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/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