sfq 0.0.29__tar.gz → 0.0.31__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.29
3
+ Version: 0.0.31
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sfq"
3
- version = "0.0.29"
3
+ version = "0.0.31"
4
4
  description = "Python wrapper for the Salesforce's Query API."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "David Moruzzi", email = "sfq.pypi@dmoruzi.com" }]
@@ -10,6 +10,7 @@ import os
10
10
  import re
11
11
  import time
12
12
  import warnings
13
+ import webbrowser
13
14
  import xml.etree.ElementTree as ET
14
15
  from collections import OrderedDict
15
16
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -99,7 +100,7 @@ class SFAuth:
99
100
  access_token: Optional[str] = None,
100
101
  token_expiration_time: Optional[float] = None,
101
102
  token_lifetime: int = 15 * 60,
102
- user_agent: str = "sfq/0.0.29",
103
+ user_agent: str = "sfq/0.0.31",
103
104
  sforce_client: str = "_auto",
104
105
  proxy: str = "_auto",
105
106
  ) -> None:
@@ -997,9 +998,10 @@ class SFAuth:
997
998
 
998
999
  return combined_response or None
999
1000
 
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 (
1001
+ def _gen_soap_envelope(self, header: str, body: str, type: str) -> str:
1002
+ """Generates a full SOAP envelope with all required namespaces for Salesforce API."""
1003
+ if type == "enterprise":
1004
+ return (
1003
1005
  '<?xml version="1.0" encoding="UTF-8"?>'
1004
1006
  "<soapenv:Envelope "
1005
1007
  'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
@@ -1010,8 +1012,24 @@ class SFAuth:
1010
1012
  f"{header}{body}"
1011
1013
  "</soapenv:Envelope>"
1012
1014
  )
1015
+ elif type == "tooling":
1016
+ return (
1017
+ '<?xml version="1.0" encoding="UTF-8"?>'
1018
+ "<soapenv:Envelope "
1019
+ 'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
1020
+ 'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
1021
+ 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
1022
+ 'xmlns="urn:tooling.soap.sforce.com" '
1023
+ 'xmlns:mns="urn:metadata.tooling.soap.sforce.com" '
1024
+ 'xmlns:sf="urn:sobject.tooling.soap.sforce.com">'
1025
+ f"{header}{body}"
1026
+ "</soapenv:Envelope>"
1027
+ )
1028
+ raise ValueError(
1029
+ f"Unsupported API type: {type}. Must be 'enterprise' or 'tooling'."
1030
+ )
1013
1031
 
1014
- def _gen_soap_header(self):
1032
+ def _gen_soap_header(self) -> str:
1015
1033
  """This function generates the header for the SOAP request."""
1016
1034
  headers = self._get_common_headers()
1017
1035
  session_id = headers["Authorization"].split(" ")[1]
@@ -1099,6 +1117,7 @@ class SFAuth:
1099
1117
  insert_list: List[Dict[str, Any]],
1100
1118
  batch_size: int = 200,
1101
1119
  max_workers: int = None,
1120
+ api_type: Literal["enterprise", "tooling"] = "enterprise",
1102
1121
  ) -> Optional[Dict[str, Any]]:
1103
1122
  """
1104
1123
  Execute the Insert API to insert multiple records via SOAP calls.
@@ -1110,7 +1129,18 @@ class SFAuth:
1110
1129
  :return: JSON response from the insert request or None on failure.
1111
1130
  """
1112
1131
 
1113
- endpoint = f"/services/Soap/c/{self.api_version}"
1132
+ endpoint = "/services/Soap/"
1133
+ if api_type == "enterprise":
1134
+ endpoint += f"c/{self.api_version}"
1135
+ elif api_type == "tooling":
1136
+ endpoint += f"T/{self.api_version}"
1137
+ else:
1138
+ logger.error(
1139
+ "Invalid API type: %s. Must be one of: 'enterprise', 'tooling'.",
1140
+ api_type,
1141
+ )
1142
+ return None
1143
+ endpoint = endpoint.replace('/v', '/') # handle API versioning in the endpoint
1114
1144
 
1115
1145
  if isinstance(insert_list, dict):
1116
1146
  insert_list = [insert_list]
@@ -1123,7 +1153,7 @@ class SFAuth:
1123
1153
  def insert_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
1124
1154
  header = self._gen_soap_header()
1125
1155
  body = self._gen_soap_body(sobject=sobject, method="create", data=chunk)
1126
- envelope = self._gen_soap_envelope(header, body)
1156
+ envelope = self._gen_soap_envelope(header=header, body=body, type=api_type)
1127
1157
  soap_headers = self._get_common_headers().copy()
1128
1158
  soap_headers["Content-Type"] = "text/xml; charset=UTF-8"
1129
1159
  soap_headers["SOAPAction"] = '""'
@@ -1165,3 +1195,33 @@ class SFAuth:
1165
1195
  ]
1166
1196
 
1167
1197
  return combined_response or None
1198
+
1199
+ def _debug_cleanup_apex_logs(self):
1200
+ """
1201
+ This function performs cleanup operations for Apex debug logs.
1202
+ """
1203
+ apex_logs = self.query("SELECT Id FROM ApexLog ORDER BY LogLength DESC")
1204
+ if apex_logs and apex_logs.get("records"):
1205
+ log_ids = [log["Id"] for log in apex_logs["records"]]
1206
+ if log_ids:
1207
+ delete_response = self.cdelete(log_ids)
1208
+ logger.debug("Deleted Apex logs: %s", delete_response)
1209
+ else:
1210
+ logger.debug("No Apex logs found to delete.")
1211
+
1212
+ def debug_cleanup(self, apex_logs: bool = True) -> None:
1213
+ """
1214
+ Perform cleanup operations for Apex debug logs.
1215
+ """
1216
+ if apex_logs:
1217
+ self._debug_cleanup_apex_logs()
1218
+
1219
+ def open_frontdoor(self) -> None:
1220
+ """
1221
+ This function opens the Salesforce Frontdoor URL in the default web browser.
1222
+ """
1223
+ if not self.access_token:
1224
+ self._get_common_headers()
1225
+ sid = quote(self.access_token, safe="")
1226
+ frontdoor_url = f"{self.instance_url}/secur/frontdoor.jsp?sid={sid}"
1227
+ webbrowser.open(frontdoor_url)
@@ -0,0 +1,148 @@
1
+ import http.client
2
+ import json
3
+ import os
4
+ import sys
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+ from time import sleep
8
+ from urllib.parse import quote
9
+
10
+ import pytest
11
+
12
+ # --- Setup local import path ---
13
+ project_root = Path(__file__).resolve().parents[1]
14
+ src_path = project_root / "src"
15
+ sys.path.insert(0, str(src_path))
16
+ from sfq import SFAuth # noqa: E402
17
+
18
+
19
+ @pytest.fixture(scope="module")
20
+ def sf_instance():
21
+ required_env_vars = [
22
+ "SF_INSTANCE_URL",
23
+ "SF_CLIENT_ID",
24
+ "SF_CLIENT_SECRET",
25
+ "SF_REFRESH_TOKEN",
26
+ ]
27
+
28
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
29
+ if missing_vars:
30
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
31
+
32
+ sf = SFAuth(
33
+ instance_url=os.getenv("SF_INSTANCE_URL"),
34
+ client_id=os.getenv("SF_CLIENT_ID"),
35
+ client_secret=os.getenv("SF_CLIENT_SECRET"),
36
+ refresh_token=os.getenv("SF_REFRESH_TOKEN"),
37
+ )
38
+ return sf
39
+
40
+
41
+ def test_debug_cleanup(sf_instance, already_executed: bool = False):
42
+ """
43
+ Test the debug_cleanup method of SFAuth.
44
+ This test ensures the method deletes Apex logs as expected.
45
+ """
46
+ # Check if any Apex logs already exist
47
+ apex_logs = sf_instance.query("SELECT Id FROM ApexLog LIMIT 1")
48
+ if apex_logs.get("records"):
49
+ sf_instance.debug_cleanup(apex_logs=True)
50
+ apex_logs_after = sf_instance.query("SELECT Id FROM ApexLog LIMIT 1")
51
+ assert len(apex_logs_after.get("records", [])) == 0, (
52
+ "Apex logs were not cleaned up successfully."
53
+ )
54
+ return
55
+
56
+ # No Apex logs yet; create one via anonymous Apex
57
+ traceflag_query = sf_instance.tooling_query(
58
+ f"SELECT Id FROM TraceFlag WHERE TracedEntityId = '{sf_instance.user_id}' LIMIT 1"
59
+ )
60
+ records = traceflag_query.get("records", [])
61
+ traceflag_id = records[0].get("Id") if records else None
62
+
63
+ if not traceflag_id:
64
+ debuglevel_query = sf_instance.tooling_query(
65
+ "SELECT Id FROM DebugLevel WHERE DeveloperName = 'SFDC_DevConsole' LIMIT 1"
66
+ )
67
+ debuglevel_id = debuglevel_query.get("records", [{}])[0].get("Id")
68
+
69
+ if not debuglevel_id:
70
+ pytest.fail(
71
+ "DebugLevel 'SFDC_DevConsole' not found. Please create it in your Salesforce org."
72
+ )
73
+
74
+ traceflag_payload = {
75
+ "DebugLevelId": debuglevel_id,
76
+ "LogType": "USER_DEBUG",
77
+ "TracedEntityId": sf_instance.user_id,
78
+ "StartDate": datetime.now(timezone.utc).isoformat(),
79
+ "ExpirationDate": (
80
+ datetime.now(timezone.utc) + timedelta(minutes=5)
81
+ ).isoformat(),
82
+ }
83
+ resp = sf_instance._create(
84
+ sobject="TraceFlag", insert_list=[traceflag_payload], api_type="tooling"
85
+ )
86
+ with open("debug_payload.json", "w") as f:
87
+ f.write(json.dumps(resp, indent=2))
88
+ with open("debug_payload.json", "w") as f:
89
+ f.write(json.dumps(traceflag_payload, indent=2))
90
+
91
+ traceflag_query = sf_instance.tooling_query(
92
+ f"SELECT Id FROM TraceFlag WHERE TracedEntityId = '{sf_instance.user_id}' LIMIT 1"
93
+ )
94
+ records = traceflag_query.get("records", [])
95
+ traceflag_id = records[0].get("Id") if records else None
96
+
97
+ if not traceflag_id:
98
+ pytest.fail("Failed to create TraceFlag.")
99
+
100
+ else:
101
+ # Update the existing TraceFlag's dates
102
+ starttime = datetime.now(timezone.utc).isoformat()
103
+ endtime = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat()
104
+ payload = json.dumps({"StartDate": starttime, "ExpirationDate": endtime})
105
+
106
+ conn = http.client.HTTPSConnection(
107
+ sf_instance.instance_url.replace("https://", "")
108
+ )
109
+ conn.request(
110
+ "PATCH",
111
+ f"/services/data/v64.0/tooling/sobjects/TraceFlag/{traceflag_id}",
112
+ body=payload,
113
+ headers={
114
+ "Authorization": f"Bearer {sf_instance.access_token}",
115
+ "Content-Type": "application/json",
116
+ },
117
+ )
118
+ response = conn.getresponse()
119
+ resp_body = response.read().decode()
120
+
121
+ if response.status not in (200, 204):
122
+ pytest.fail(
123
+ f"Failed to update TraceFlag: {response.reason} | Body: {resp_body}"
124
+ )
125
+
126
+ # Now generate an Apex log
127
+ anonymous_body = f"System.debug('Hello from {sf_instance.user_agent}! :)');"
128
+ encoded_body = quote(anonymous_body, safe="")
129
+ conn = http.client.HTTPSConnection(sf_instance.instance_url.replace("https://", ""))
130
+ conn.request(
131
+ "GET",
132
+ f"/services/data/v64.0/tooling/executeAnonymous/?anonymousBody={encoded_body}",
133
+ headers={
134
+ "Authorization": f"Bearer {sf_instance.access_token}",
135
+ "Content-Type": "application/json",
136
+ },
137
+ )
138
+ response = conn.getresponse()
139
+ if response.status != 200:
140
+ pytest.fail(f"Failed to execute anonymous Apex: {response.reason}")
141
+
142
+ if already_executed:
143
+ pytest.fail(
144
+ "ApexLog creation failed or already attempted. Skipping recursion to avoid infinite loop."
145
+ )
146
+
147
+ sleep(1) # Race condition mitigation
148
+ return test_debug_cleanup(sf_instance, already_executed=True)
@@ -0,0 +1,51 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+ from urllib.parse import quote
7
+
8
+ import pytest
9
+ from pytest import fail
10
+
11
+ # --- Setup local import path ---
12
+ project_root = Path(__file__).resolve().parents[1]
13
+ src_path = project_root / "src"
14
+ sys.path.insert(0, str(src_path))
15
+ from sfq import SFAuth # noqa: E402
16
+
17
+
18
+ @pytest.fixture(scope="module")
19
+ def sf_instance():
20
+ required_env_vars = [
21
+ "SF_INSTANCE_URL",
22
+ "SF_CLIENT_ID",
23
+ "SF_CLIENT_SECRET",
24
+ "SF_REFRESH_TOKEN",
25
+ ]
26
+
27
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
28
+ if missing_vars:
29
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
30
+
31
+ return SFAuth(
32
+ instance_url=os.getenv("SF_INSTANCE_URL"),
33
+ client_id=os.getenv("SF_CLIENT_ID"),
34
+ client_secret=os.getenv("SF_CLIENT_SECRET"),
35
+ refresh_token=os.getenv("SF_REFRESH_TOKEN"),
36
+ )
37
+
38
+
39
+ def test_open_frontdoor(sf_instance):
40
+ with patch("webbrowser.open") as mock_open:
41
+ try:
42
+ sf_instance.open_frontdoor()
43
+ sid = quote(sf_instance.access_token, safe="")
44
+ expected_url = f"{sf_instance.instance_url}/secur/frontdoor.jsp?sid={sid}"
45
+ mock_open.assert_called_once_with(expected_url)
46
+ except AssertionError as e:
47
+ msg = str(e)
48
+ if sf_instance.access_token and sf_instance.access_token in msg:
49
+ msg = msg.replace(sf_instance.access_token, "[REDACTED]")
50
+ msg = re.sub(r"([?&]sid=)[^&\s]+", r"\1[REDACTED]", msg)
51
+ fail(f"Assertion failed: {msg}")
@@ -3,5 +3,5 @@ requires-python = ">=3.9"
3
3
 
4
4
  [[package]]
5
5
  name = "sfq"
6
- version = "0.0.29"
6
+ version = "0.0.31"
7
7
  source = { editable = "." }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes