sfq 0.0.27__py3-none-any.whl → 0.0.29__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
@@ -7,11 +7,13 @@ import http.client
|
|
7
7
|
import json
|
8
8
|
import logging
|
9
9
|
import os
|
10
|
+
import re
|
10
11
|
import time
|
11
12
|
import warnings
|
13
|
+
import xml.etree.ElementTree as ET
|
12
14
|
from collections import OrderedDict
|
13
15
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
14
|
-
from typing import Any, Dict, Iterable, Literal, Optional,
|
16
|
+
from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
|
15
17
|
from urllib.parse import quote, urlparse
|
16
18
|
|
17
19
|
__all__ = ["SFAuth"] # https://pdoc.dev/docs/pdoc.html#control-what-is-documented
|
@@ -24,7 +26,7 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
|
|
24
26
|
"""Custom TRACE level logging function with redaction."""
|
25
27
|
|
26
28
|
def _redact_sensitive(data: Any) -> Any:
|
27
|
-
"""Redacts sensitive keys from a dictionary
|
29
|
+
"""Redacts sensitive keys from a dictionary, query string, or sessionId."""
|
28
30
|
REDACT_VALUE = "*" * 8
|
29
31
|
REDACT_KEYS = [
|
30
32
|
"access_token",
|
@@ -33,6 +35,7 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
|
|
33
35
|
"cookie",
|
34
36
|
"refresh_token",
|
35
37
|
"client_secret",
|
38
|
+
"sessionid",
|
36
39
|
]
|
37
40
|
if isinstance(data, dict):
|
38
41
|
return {
|
@@ -49,6 +52,12 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
|
|
49
52
|
)
|
50
53
|
)
|
51
54
|
elif isinstance(data, str):
|
55
|
+
if "<sessionId>" in data and "</sessionId>" in data:
|
56
|
+
data = re.sub(
|
57
|
+
r"(<sessionId>)(.*?)(</sessionId>)",
|
58
|
+
r"\1{}\3".format(REDACT_VALUE),
|
59
|
+
data,
|
60
|
+
)
|
52
61
|
parts = data.split("&")
|
53
62
|
for i, part in enumerate(parts):
|
54
63
|
if "=" in part:
|
@@ -90,7 +99,7 @@ class SFAuth:
|
|
90
99
|
access_token: Optional[str] = None,
|
91
100
|
token_expiration_time: Optional[float] = None,
|
92
101
|
token_lifetime: int = 15 * 60,
|
93
|
-
user_agent: str = "sfq/0.0.
|
102
|
+
user_agent: str = "sfq/0.0.29",
|
94
103
|
sforce_client: str = "_auto",
|
95
104
|
proxy: str = "_auto",
|
96
105
|
) -> None:
|
@@ -828,7 +837,9 @@ class SFAuth:
|
|
828
837
|
for i, result in enumerate(results):
|
829
838
|
key = batch_keys[i]
|
830
839
|
if result.get("statusCode") == 200 and "result" in result:
|
831
|
-
paginated = self._paginate_query_result(
|
840
|
+
paginated = self._paginate_query_result(
|
841
|
+
result["result"], headers
|
842
|
+
)
|
832
843
|
batch_results[key] = paginated
|
833
844
|
else:
|
834
845
|
logger.error("Query failed for key %s: %s", key, result)
|
@@ -854,7 +865,9 @@ class SFAuth:
|
|
854
865
|
for i in range(0, len(keys), BATCH_SIZE):
|
855
866
|
batch_keys = keys[i : i + BATCH_SIZE]
|
856
867
|
batch_queries = [query_dict[key] for key in batch_keys]
|
857
|
-
futures.append(
|
868
|
+
futures.append(
|
869
|
+
executor.submit(_execute_batch, batch_keys, batch_queries)
|
870
|
+
)
|
858
871
|
|
859
872
|
for future in as_completed(futures):
|
860
873
|
results_dict.update(future.result())
|
@@ -910,12 +923,14 @@ class SFAuth:
|
|
910
923
|
]
|
911
924
|
return combined_response or None
|
912
925
|
|
913
|
-
def _cupdate(
|
926
|
+
def _cupdate(
|
927
|
+
self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None
|
928
|
+
) -> Optional[Dict[str, Any]]:
|
914
929
|
"""
|
915
930
|
Execute the Composite Update API to update multiple records.
|
916
931
|
|
917
932
|
: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:
|
918
|
-
{'001aj00000C8kJhAAJ': {'Subject': 'Easily updated via SFQ'}, '00aaj000006wtdcAAA': {'_': 'CaseComment', 'IsPublished': False}, '001aj0000002yJRCAY': {'_': 'IdeaComment', 'CommentBody': 'Hello World!'}}
|
933
|
+
{'001aj00000C8kJhAAJ': {'Subject': 'Easily updated via SFQ'}, '00aaj000006wtdcAAA': {'_': 'CaseComment', 'IsPublished': False}, '001aj0000002yJRCAY': {'_': 'IdeaComment', 'CommentBody': 'Hello World!'}}
|
919
934
|
:param batch_size: The number of records to update in each batch (default is 25).
|
920
935
|
:return: JSON response from the update request or None on failure.
|
921
936
|
"""
|
@@ -929,26 +944,26 @@ class SFAuth:
|
|
929
944
|
sobject = record.copy().pop("_", None)
|
930
945
|
if not sobject and not sobject_prefixes:
|
931
946
|
sobject_prefixes = self.get_sobject_prefixes()
|
932
|
-
|
947
|
+
|
933
948
|
if not sobject:
|
934
949
|
sobject = str(sobject_prefixes.get(str(key[:3]), None))
|
935
|
-
|
950
|
+
|
936
951
|
compositeRequest_payload.append(
|
937
952
|
{
|
938
|
-
|
939
|
-
|
940
|
-
|
941
|
-
|
953
|
+
"method": "PATCH",
|
954
|
+
"url": f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
|
955
|
+
"referenceId": key,
|
956
|
+
"body": record,
|
942
957
|
}
|
943
958
|
)
|
944
959
|
|
945
|
-
chunks = [
|
960
|
+
chunks = [
|
961
|
+
compositeRequest_payload[i : i + batch_size]
|
962
|
+
for i in range(0, len(compositeRequest_payload), batch_size)
|
963
|
+
]
|
946
964
|
|
947
965
|
def update_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
948
|
-
payload = {
|
949
|
-
"allOrNone": bool(allOrNone),
|
950
|
-
"compositeRequest": chunk
|
951
|
-
}
|
966
|
+
payload = {"allOrNone": bool(allOrNone), "compositeRequest": chunk}
|
952
967
|
|
953
968
|
status_code, resp_data = self._send_request(
|
954
969
|
method="POST",
|
@@ -979,5 +994,174 @@ class SFAuth:
|
|
979
994
|
for item in (result if isinstance(result, list) else [result])
|
980
995
|
if isinstance(result, (dict, list))
|
981
996
|
]
|
982
|
-
|
997
|
+
|
998
|
+
return combined_response or None
|
999
|
+
|
1000
|
+
def _gen_soap_envelope(self, header: str, body: str) -> str:
|
1001
|
+
"""Generates a full SOAP envelope with all required namespaces for Salesforce Enterprise API."""
|
1002
|
+
return (
|
1003
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
1004
|
+
"<soapenv:Envelope "
|
1005
|
+
'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
|
1006
|
+
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
|
1007
|
+
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
|
1008
|
+
'xmlns="urn:enterprise.soap.sforce.com" '
|
1009
|
+
'xmlns:sf="urn:sobject.enterprise.soap.sforce.com">'
|
1010
|
+
f"{header}{body}"
|
1011
|
+
"</soapenv:Envelope>"
|
1012
|
+
)
|
1013
|
+
|
1014
|
+
def _gen_soap_header(self):
|
1015
|
+
"""This function generates the header for the SOAP request."""
|
1016
|
+
headers = self._get_common_headers()
|
1017
|
+
session_id = headers["Authorization"].split(" ")[1]
|
1018
|
+
return f"<soapenv:Header><SessionHeader><sessionId>{session_id}</sessionId></SessionHeader></soapenv:Header>"
|
1019
|
+
|
1020
|
+
def _extract_soap_result_fields(self, xml_string: str) -> Optional[Dict[str, Any]]:
|
1021
|
+
"""
|
1022
|
+
Parse SOAP XML and extract all child fields from <result> as a dict.
|
1023
|
+
"""
|
1024
|
+
|
1025
|
+
def strip_ns(tag):
|
1026
|
+
return tag.split("}", 1)[-1] if "}" in tag else tag
|
1027
|
+
|
1028
|
+
try:
|
1029
|
+
root = ET.fromstring(xml_string)
|
1030
|
+
results = []
|
1031
|
+
for result in root.iter():
|
1032
|
+
if result.tag.endswith("result"):
|
1033
|
+
out = {}
|
1034
|
+
for child in result:
|
1035
|
+
out[strip_ns(child.tag)] = child.text
|
1036
|
+
results.append(out)
|
1037
|
+
if not results:
|
1038
|
+
return None
|
1039
|
+
if len(results) == 1:
|
1040
|
+
return results[0]
|
1041
|
+
return results
|
1042
|
+
except ET.ParseError as e:
|
1043
|
+
logger.error("Failed to parse SOAP XML: %s", e)
|
1044
|
+
return None
|
1045
|
+
|
1046
|
+
def _gen_soap_body(self, sobject: str, method: str, data: Dict[str, Any]) -> str:
|
1047
|
+
"""Generates a compact SOAP request body for one or more records."""
|
1048
|
+
# Accept both a single dict and a list of dicts
|
1049
|
+
if isinstance(data, dict):
|
1050
|
+
records = [data]
|
1051
|
+
else:
|
1052
|
+
records = data
|
1053
|
+
sobjects = "".join(
|
1054
|
+
f'<sObjects xsi:type="{sobject}">'
|
1055
|
+
+ "".join(f"<{k}>{v}</{k}>" for k, v in record.items())
|
1056
|
+
+ "</sObjects>"
|
1057
|
+
for record in records
|
1058
|
+
)
|
1059
|
+
return f"<soapenv:Body><{method}>{sobjects}</{method}></soapenv:Body>"
|
1060
|
+
|
1061
|
+
def _xml_to_json(self, xml_string: str) -> Optional[Dict[str, Any]]:
|
1062
|
+
"""
|
1063
|
+
Convert an XML string to a JSON-like dictionary.
|
1064
|
+
|
1065
|
+
:param xml_string: The XML string to convert.
|
1066
|
+
:return: A dictionary representation of the XML or None on failure.
|
1067
|
+
"""
|
1068
|
+
try:
|
1069
|
+
root = ET.fromstring(xml_string)
|
1070
|
+
return self._xml_to_dict(root)
|
1071
|
+
except ET.ParseError as e:
|
1072
|
+
logger.error("Failed to parse XML: %s", e)
|
1073
|
+
return None
|
1074
|
+
|
1075
|
+
def _xml_to_dict(self, element: ET.Element) -> Dict[str, Any]:
|
1076
|
+
"""
|
1077
|
+
Recursively convert an XML Element to a dictionary.
|
1078
|
+
|
1079
|
+
:param element: The XML Element to convert.
|
1080
|
+
:return: A dictionary representation of the XML Element.
|
1081
|
+
"""
|
1082
|
+
if len(element) == 0:
|
1083
|
+
return element.text or ""
|
1084
|
+
|
1085
|
+
result = {}
|
1086
|
+
for child in element:
|
1087
|
+
child_dict = self._xml_to_dict(child)
|
1088
|
+
if child.tag not in result:
|
1089
|
+
result[child.tag] = child_dict
|
1090
|
+
else:
|
1091
|
+
if not isinstance(result[child.tag], list):
|
1092
|
+
result[child.tag] = [result[child.tag]]
|
1093
|
+
result[child.tag].append(child_dict)
|
1094
|
+
return result
|
1095
|
+
|
1096
|
+
def _create( # I don't like this name, will think of a better one later...as such, not public.
|
1097
|
+
self,
|
1098
|
+
sobject: str,
|
1099
|
+
insert_list: List[Dict[str, Any]],
|
1100
|
+
batch_size: int = 200,
|
1101
|
+
max_workers: int = None,
|
1102
|
+
) -> Optional[Dict[str, Any]]:
|
1103
|
+
"""
|
1104
|
+
Execute the Insert API to insert multiple records via SOAP calls.
|
1105
|
+
|
1106
|
+
:param sobject: The name of the sObject to insert into.
|
1107
|
+
:param insert_list: A list of dictionaries, each representing a record to insert. Example: [{'Subject': 'Easily inserted via SFQ'}]
|
1108
|
+
:param batch_size: The number of records to insert in each batch (default is 200).
|
1109
|
+
:param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
|
1110
|
+
:return: JSON response from the insert request or None on failure.
|
1111
|
+
"""
|
1112
|
+
|
1113
|
+
endpoint = f"/services/Soap/c/{self.api_version}"
|
1114
|
+
|
1115
|
+
if isinstance(insert_list, dict):
|
1116
|
+
insert_list = [insert_list]
|
1117
|
+
|
1118
|
+
chunks = [
|
1119
|
+
insert_list[i : i + batch_size]
|
1120
|
+
for i in range(0, len(insert_list), batch_size)
|
1121
|
+
]
|
1122
|
+
|
1123
|
+
def insert_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
1124
|
+
header = self._gen_soap_header()
|
1125
|
+
body = self._gen_soap_body(sobject=sobject, method="create", data=chunk)
|
1126
|
+
envelope = self._gen_soap_envelope(header, body)
|
1127
|
+
soap_headers = self._get_common_headers().copy()
|
1128
|
+
soap_headers["Content-Type"] = "text/xml; charset=UTF-8"
|
1129
|
+
soap_headers["SOAPAction"] = '""'
|
1130
|
+
|
1131
|
+
logger.trace("SOAP request envelope: %s", envelope)
|
1132
|
+
logger.trace("SOAP request headers: %s", soap_headers)
|
1133
|
+
status_code, resp_data = self._send_request(
|
1134
|
+
method="POST",
|
1135
|
+
endpoint=endpoint,
|
1136
|
+
headers=soap_headers,
|
1137
|
+
body=envelope,
|
1138
|
+
)
|
1139
|
+
|
1140
|
+
if status_code == 200:
|
1141
|
+
logger.debug("Insert API request successful.")
|
1142
|
+
logger.trace("Insert API response: %s", resp_data)
|
1143
|
+
result = self._extract_soap_result_fields(resp_data)
|
1144
|
+
if result:
|
1145
|
+
return result
|
1146
|
+
logger.error("Failed to extract fields from SOAP response.")
|
1147
|
+
else:
|
1148
|
+
logger.error("Insert API request failed: %s", status_code)
|
1149
|
+
logger.debug("Response body: %s", resp_data)
|
1150
|
+
return None
|
1151
|
+
|
1152
|
+
results = []
|
1153
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
1154
|
+
futures = [executor.submit(insert_chunk, chunk) for chunk in chunks]
|
1155
|
+
for future in as_completed(futures):
|
1156
|
+
result = future.result()
|
1157
|
+
if result:
|
1158
|
+
results.append(result)
|
1159
|
+
|
1160
|
+
combined_response = [
|
1161
|
+
item
|
1162
|
+
for result in results
|
1163
|
+
for item in (result if isinstance(result, list) else [result])
|
1164
|
+
if isinstance(result, (dict, list))
|
1165
|
+
]
|
1166
|
+
|
983
1167
|
return combined_response or None
|
@@ -0,0 +1,6 @@
|
|
1
|
+
sfq/__init__.py,sha256=PCvN7WBs0krbGc9PUZUYdan3L67iQkZCp77_EvrOGKo,44356
|
2
|
+
sfq/_cometd.py,sha256=XimQEubmJwUmbWe85TxH_cuhGvWVuiHHrVr41tguuiI,10508
|
3
|
+
sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
+
sfq-0.0.29.dist-info/METADATA,sha256=G4tled8wgy1_Rav2UlF4-_T5P54e6Wv0ozi-HZGP9xc,6899
|
5
|
+
sfq-0.0.29.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
6
|
+
sfq-0.0.29.dist-info/RECORD,,
|
sfq-0.0.27.dist-info/RECORD
DELETED
@@ -1,6 +0,0 @@
|
|
1
|
-
sfq/__init__.py,sha256=GD7ObGvJAjHR7A-i11qoLQmoCA6d7qlu7DWltGqP8t8,37139
|
2
|
-
sfq/_cometd.py,sha256=XimQEubmJwUmbWe85TxH_cuhGvWVuiHHrVr41tguuiI,10508
|
3
|
-
sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
-
sfq-0.0.27.dist-info/METADATA,sha256=W3i5J8sNzLjQxoZHcnkA-VIXXYaQap1OfnFAMT1Nzag,6899
|
5
|
-
sfq-0.0.27.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
6
|
-
sfq-0.0.27.dist-info/RECORD,,
|
File without changes
|