sfq 0.0.27__py3-none-any.whl → 0.0.28__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, List, Tuple
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 or query string."""
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.27",
102
+ user_agent: str = "sfq/0.0.28",
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(result["result"], headers)
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(executor.submit(_execute_batch, batch_keys, batch_queries))
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(self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None) -> Optional[Dict[str, Any]]:
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
- 'method': 'PATCH',
939
- 'url': f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
940
- 'referenceId': key,
941
- 'body': record,
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 = [compositeRequest_payload[i:i+batch_size] for i in range(0, len(compositeRequest_payload), batch_size)]
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.27
3
+ Version: 0.0.28
4
4
  Summary: Python wrapper for the Salesforce's Query API.
5
5
  Author-email: David Moruzzi <sfq.pypi@dmoruzi.com>
6
6
  Keywords: salesforce,salesforce query
@@ -0,0 +1,6 @@
1
+ sfq/__init__.py,sha256=NNtMkZ9nLn4ItJ2vhBHlHViJ_c2OWFZM6NUbUnOnrtE,44356
2
+ sfq/_cometd.py,sha256=XimQEubmJwUmbWe85TxH_cuhGvWVuiHHrVr41tguuiI,10508
3
+ sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sfq-0.0.28.dist-info/METADATA,sha256=Loc_qnLzns7FfFK9ZfBUHreeWqyQMYNQQTs81_jKeR0,6899
5
+ sfq-0.0.28.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ sfq-0.0.28.dist-info/RECORD,,
@@ -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