sfq 0.0.6__py3-none-any.whl → 0.0.8__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 +308 -78
- {sfq-0.0.6.dist-info → sfq-0.0.8.dist-info}/METADATA +9 -28
- sfq-0.0.8.dist-info/RECORD +5 -0
- {sfq-0.0.6.dist-info → sfq-0.0.8.dist-info}/WHEEL +1 -1
- sfq/__main__.py +0 -151
- sfq-0.0.6.dist-info/RECORD +0 -6
sfq/__init__.py
CHANGED
@@ -1,37 +1,91 @@
|
|
1
1
|
import http.client
|
2
|
+
import json
|
2
3
|
import logging
|
3
|
-
import time
|
4
4
|
import os
|
5
|
-
import
|
6
|
-
from
|
5
|
+
import time
|
6
|
+
from typing import Any, Dict, Optional
|
7
|
+
from urllib.parse import quote, urlparse
|
8
|
+
|
9
|
+
TRACE = 5
|
10
|
+
logging.addLevelName(TRACE, "TRACE")
|
11
|
+
|
12
|
+
|
13
|
+
def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
|
14
|
+
"""Custom TRACE level logging function with redaction."""
|
15
|
+
|
16
|
+
def _redact_sensitive(data: Any) -> Any:
|
17
|
+
"""Redacts sensitive keys from a dictionary or query string."""
|
18
|
+
REDACT_VALUE = "*" * 8
|
19
|
+
if isinstance(data, dict):
|
20
|
+
return {
|
21
|
+
k: (
|
22
|
+
REDACT_VALUE
|
23
|
+
if k.lower() in ["access_token", "authorization", "refresh_token"]
|
24
|
+
else v
|
25
|
+
)
|
26
|
+
for k, v in data.items()
|
27
|
+
}
|
28
|
+
elif isinstance(data, str):
|
29
|
+
parts = data.split("&")
|
30
|
+
for i, part in enumerate(parts):
|
31
|
+
if "=" in part:
|
32
|
+
key, value = part.split("=", 1)
|
33
|
+
if key.lower() in [
|
34
|
+
"access_token",
|
35
|
+
"authorization",
|
36
|
+
"refresh_token",
|
37
|
+
]:
|
38
|
+
parts[i] = f"{key}={REDACT_VALUE}"
|
39
|
+
return "&".join(parts)
|
40
|
+
return data
|
41
|
+
|
42
|
+
redacted_args = args
|
43
|
+
if args:
|
44
|
+
first = args[0]
|
45
|
+
if isinstance(first, str):
|
46
|
+
try:
|
47
|
+
loaded = json.loads(first)
|
48
|
+
first = loaded
|
49
|
+
except (json.JSONDecodeError, TypeError):
|
50
|
+
pass
|
51
|
+
redacted_first = _redact_sensitive(first)
|
52
|
+
redacted_args = (redacted_first,) + args[1:]
|
53
|
+
|
54
|
+
if self.isEnabledFor(TRACE):
|
55
|
+
self._log(TRACE, message, redacted_args, **kwargs)
|
56
|
+
|
57
|
+
|
58
|
+
logging.Logger.trace = trace
|
59
|
+
logger = logging.getLogger("sfq")
|
7
60
|
|
8
|
-
logging.basicConfig(level=logging.INFO, format='[sfq:%(lineno)d - %(levelname)s] %(message)s')
|
9
61
|
|
10
62
|
class SFAuth:
|
11
63
|
def __init__(
|
12
64
|
self,
|
13
|
-
instance_url,
|
14
|
-
client_id,
|
15
|
-
refresh_token,
|
16
|
-
api_version="v63.0",
|
17
|
-
token_endpoint="/services/oauth2/token",
|
18
|
-
access_token=None,
|
19
|
-
token_expiration_time=None,
|
20
|
-
token_lifetime=15 * 60,
|
21
|
-
|
22
|
-
|
65
|
+
instance_url: str,
|
66
|
+
client_id: str,
|
67
|
+
refresh_token: str,
|
68
|
+
api_version: str = "v63.0",
|
69
|
+
token_endpoint: str = "/services/oauth2/token",
|
70
|
+
access_token: Optional[str] = None,
|
71
|
+
token_expiration_time: Optional[float] = None,
|
72
|
+
token_lifetime: int = 15 * 60,
|
73
|
+
user_agent: str = "sfq/0.0.8",
|
74
|
+
proxy: str = "auto",
|
75
|
+
) -> None:
|
23
76
|
"""
|
24
77
|
Initializes the SFAuth with necessary parameters.
|
25
78
|
|
26
79
|
:param instance_url: The Salesforce instance URL.
|
27
80
|
:param client_id: The client ID for OAuth.
|
28
81
|
:param refresh_token: The refresh token for OAuth.
|
29
|
-
:param api_version: The Salesforce API version (default is "
|
82
|
+
:param api_version: The Salesforce API version (default is "v63.0").
|
30
83
|
:param token_endpoint: The token endpoint (default is "/services/oauth2/token").
|
31
84
|
:param access_token: The access token for the current session (default is None).
|
32
85
|
:param token_expiration_time: The expiration time of the access token (default is None).
|
33
|
-
:param token_lifetime: The lifetime of the access token (default is 15 minutes).
|
34
|
-
:param
|
86
|
+
:param token_lifetime: The lifetime of the access token in seconds (default is 15 minutes).
|
87
|
+
:param user_agent: Custom User-Agent string (default is "sfq/0.0.8").
|
88
|
+
:param proxy: The proxy configuration, "auto" to use environment (default is "auto").
|
35
89
|
"""
|
36
90
|
self.instance_url = instance_url
|
37
91
|
self.client_id = client_id
|
@@ -41,131 +95,307 @@ class SFAuth:
|
|
41
95
|
self.access_token = access_token
|
42
96
|
self.token_expiration_time = token_expiration_time
|
43
97
|
self.token_lifetime = token_lifetime
|
98
|
+
self.user_agent = user_agent
|
44
99
|
self._auto_configure_proxy(proxy)
|
100
|
+
self._high_api_usage_threshold = 80
|
45
101
|
|
46
|
-
def _auto_configure_proxy(self, proxy):
|
102
|
+
def _auto_configure_proxy(self, proxy: str) -> None:
|
47
103
|
"""
|
48
|
-
Automatically configure the proxy based on the environment.
|
104
|
+
Automatically configure the proxy based on the environment or provided value.
|
49
105
|
"""
|
50
106
|
if proxy == "auto":
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
self.proxy = None
|
107
|
+
self.proxy = os.environ.get("https_proxy")
|
108
|
+
if self.proxy:
|
109
|
+
logger.debug("Auto-configured proxy: %s", self.proxy)
|
55
110
|
else:
|
56
111
|
self.proxy = proxy
|
112
|
+
logger.debug("Using configured proxy: %s", self.proxy)
|
57
113
|
|
58
|
-
def _prepare_payload(self):
|
59
|
-
"""
|
114
|
+
def _prepare_payload(self) -> Dict[str, str]:
|
115
|
+
"""
|
116
|
+
Prepare the payload for the token request.
|
117
|
+
"""
|
60
118
|
return {
|
61
119
|
"grant_type": "refresh_token",
|
62
120
|
"client_id": self.client_id,
|
63
121
|
"refresh_token": self.refresh_token,
|
64
122
|
}
|
65
123
|
|
66
|
-
def _create_connection(self, netloc):
|
124
|
+
def _create_connection(self, netloc: str) -> http.client.HTTPConnection:
|
67
125
|
"""
|
68
|
-
Create a connection using HTTP or HTTPS,
|
126
|
+
Create a connection using HTTP or HTTPS, with optional proxy support.
|
127
|
+
|
128
|
+
:param netloc: The target host and port from the parsed instance URL.
|
129
|
+
:return: An HTTP(S)Connection object.
|
69
130
|
"""
|
70
131
|
if self.proxy:
|
71
132
|
proxy_url = urlparse(self.proxy)
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
133
|
+
conn_cls = (
|
134
|
+
http.client.HTTPSConnection
|
135
|
+
if proxy_url.scheme != "http"
|
136
|
+
else http.client.HTTPConnection
|
137
|
+
)
|
138
|
+
conn = conn_cls(proxy_url.hostname, proxy_url.port)
|
76
139
|
conn.set_tunnel(netloc)
|
140
|
+
logger.trace("Using proxy tunnel to %s", netloc)
|
77
141
|
else:
|
78
142
|
conn = http.client.HTTPSConnection(netloc)
|
143
|
+
logger.trace("Direct connection to %s", netloc)
|
79
144
|
return conn
|
80
145
|
|
81
|
-
def
|
82
|
-
"""
|
146
|
+
def _post_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
147
|
+
"""
|
148
|
+
Send a POST request to the Salesforce token endpoint using http.client.
|
149
|
+
|
150
|
+
:param payload: Dictionary of form-encoded OAuth parameters.
|
151
|
+
:return: Parsed JSON response if successful, otherwise None.
|
152
|
+
"""
|
83
153
|
parsed_url = urlparse(self.instance_url)
|
84
154
|
conn = self._create_connection(parsed_url.netloc)
|
85
|
-
|
86
|
-
|
87
|
-
|
155
|
+
headers = {
|
156
|
+
"Accept": "application/json",
|
157
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
158
|
+
"User-Agent": self.user_agent,
|
159
|
+
}
|
160
|
+
body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
88
161
|
|
89
162
|
try:
|
163
|
+
logger.trace("Request endpoint: %s", self.token_endpoint)
|
164
|
+
logger.trace("Request body: %s", body)
|
165
|
+
logger.trace("Request headers: %s", headers)
|
90
166
|
conn.request("POST", self.token_endpoint, body, headers)
|
91
167
|
response = conn.getresponse()
|
92
168
|
data = response.read().decode("utf-8")
|
169
|
+
self._http_resp_header_logic(response)
|
170
|
+
|
93
171
|
if response.status == 200:
|
172
|
+
logger.trace("Token refresh successful.")
|
173
|
+
logger.trace("Response body: %s", data)
|
94
174
|
return json.loads(data)
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
175
|
+
|
176
|
+
logger.error(
|
177
|
+
"Token refresh failed: %s %s", response.status, response.reason
|
178
|
+
)
|
179
|
+
logger.debug("Response body: %s", data)
|
180
|
+
|
100
181
|
except Exception as err:
|
101
|
-
|
182
|
+
logger.exception("Error during token request: %s", err)
|
183
|
+
|
102
184
|
finally:
|
103
185
|
conn.close()
|
104
186
|
|
105
187
|
return None
|
106
188
|
|
107
|
-
def
|
108
|
-
"""
|
109
|
-
|
110
|
-
|
189
|
+
def _http_resp_header_logic(self, response: http.client.HTTPResponse) -> None:
|
190
|
+
"""
|
191
|
+
Perform additional logic based on the HTTP response headers.
|
192
|
+
|
193
|
+
:param response: The HTTP response object.
|
194
|
+
:return: None
|
195
|
+
"""
|
196
|
+
logger.trace(
|
197
|
+
"Response status: %s, reason: %s", response.status, response.reason
|
198
|
+
)
|
199
|
+
headers = response.getheaders()
|
200
|
+
headers_list = [(k, v) for k, v in headers if not v.startswith("BrowserId=")]
|
201
|
+
logger.trace("Response headers: %s", headers_list)
|
202
|
+
for key, value in headers_list:
|
203
|
+
if key.startswith("Sforce-"):
|
204
|
+
if key == "Sforce-Limit-Info":
|
205
|
+
current_api_calls = int(value.split("=")[1].split("/")[0])
|
206
|
+
maximum_api_calls = int(value.split("=")[1].split("/")[1])
|
207
|
+
usage_percentage = round(
|
208
|
+
current_api_calls / maximum_api_calls * 100, 2
|
209
|
+
)
|
210
|
+
if usage_percentage > self._high_api_usage_threshold:
|
211
|
+
logger.warning(
|
212
|
+
"High API usage: %s/%s (%s%%)",
|
213
|
+
current_api_calls,
|
214
|
+
maximum_api_calls,
|
215
|
+
usage_percentage,
|
216
|
+
)
|
217
|
+
else:
|
218
|
+
logger.debug(
|
219
|
+
"API usage: %s/%s (%s%%)",
|
220
|
+
current_api_calls,
|
221
|
+
maximum_api_calls,
|
222
|
+
usage_percentage,
|
223
|
+
)
|
224
|
+
|
225
|
+
def _refresh_token_if_needed(self) -> Optional[str]:
|
226
|
+
"""
|
227
|
+
Automatically refresh the access token if it has expired or is missing.
|
228
|
+
|
229
|
+
:return: A valid access token or None if refresh failed.
|
230
|
+
"""
|
231
|
+
if self.access_token and not self._is_token_expired():
|
111
232
|
return self.access_token
|
112
|
-
|
113
|
-
if not self.access_token:
|
114
|
-
logging.debug("No access token available. Requesting a new one.")
|
115
|
-
elif _token_expiration:
|
116
|
-
logging.debug("Access token has expired. Requesting a new one.")
|
117
233
|
|
234
|
+
logger.trace("Access token expired or missing, refreshing...")
|
118
235
|
payload = self._prepare_payload()
|
119
|
-
token_data = self.
|
236
|
+
token_data = self._post_token_request(payload)
|
237
|
+
|
120
238
|
if token_data:
|
121
|
-
self.access_token = token_data
|
122
|
-
|
123
|
-
logging.debug("Access token refreshed successfully.")
|
124
|
-
else:
|
125
|
-
logging.error("Failed to refresh access token.")
|
126
|
-
return self.access_token
|
239
|
+
self.access_token = token_data.get("access_token")
|
240
|
+
issued_at = token_data.get("issued_at")
|
127
241
|
|
242
|
+
try:
|
243
|
+
self.org_id = token_data.get("id").split("/")[4]
|
244
|
+
self.user_id = token_data.get("id").split("/")[5]
|
245
|
+
logger.trace(
|
246
|
+
"Authenticated as user %s in org %s", self.user_id, self.org_id
|
247
|
+
)
|
248
|
+
except (IndexError, KeyError):
|
249
|
+
logger.error("Failed to extract org/user IDs from token response.")
|
250
|
+
|
251
|
+
if self.access_token and issued_at:
|
252
|
+
self.token_expiration_time = int(issued_at) + self.token_lifetime
|
253
|
+
logger.trace("New token expires at %s", self.token_expiration_time)
|
254
|
+
return self.access_token
|
128
255
|
|
129
|
-
|
130
|
-
|
256
|
+
logger.error("Failed to obtain access token.")
|
257
|
+
return None
|
258
|
+
|
259
|
+
def _is_token_expired(self) -> bool:
|
260
|
+
"""
|
261
|
+
Check if the access token has expired.
|
262
|
+
|
263
|
+
:return: True if token is expired or missing, False otherwise.
|
264
|
+
"""
|
131
265
|
try:
|
132
266
|
return time.time() >= float(self.token_expiration_time)
|
133
267
|
except (TypeError, ValueError):
|
268
|
+
logger.warning("Token expiration check failed. Treating token as expired.")
|
134
269
|
return True
|
135
270
|
|
136
|
-
def
|
137
|
-
"""
|
271
|
+
def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
|
272
|
+
"""
|
273
|
+
Execute a SOQL query using the Tooling API.
|
274
|
+
|
275
|
+
:param query: The SOQL query string.
|
276
|
+
:return: Parsed JSON response or None on failure.
|
277
|
+
"""
|
278
|
+
return self.query(query, tooling=True)
|
279
|
+
|
280
|
+
def limits(self) -> Optional[Dict[str, Any]]:
|
138
281
|
self._refresh_token_if_needed()
|
139
282
|
|
140
283
|
if not self.access_token:
|
141
|
-
|
284
|
+
logger.error("No access token available for limits.")
|
142
285
|
return None
|
143
286
|
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
# Handle special characters in the query
|
152
|
-
encoded_query = quote(query)
|
153
|
-
params = f"?q={encoded_query}"
|
287
|
+
endpoint = f"/services/data/{self.api_version}/limits"
|
288
|
+
headers = {
|
289
|
+
"Authorization": f"Bearer {self.access_token}",
|
290
|
+
"User-Agent": self.user_agent,
|
291
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
292
|
+
"Accept": "application/json",
|
293
|
+
}
|
154
294
|
|
155
295
|
parsed_url = urlparse(self.instance_url)
|
156
296
|
conn = self._create_connection(parsed_url.netloc)
|
157
297
|
|
158
298
|
try:
|
159
|
-
|
299
|
+
logger.trace("Request endpoint: %s", endpoint)
|
300
|
+
logger.trace("Request headers: %s", headers)
|
301
|
+
conn.request("GET", endpoint, headers=headers)
|
160
302
|
response = conn.getresponse()
|
161
303
|
data = response.read().decode("utf-8")
|
304
|
+
self._http_resp_header_logic(response)
|
305
|
+
|
162
306
|
if response.status == 200:
|
307
|
+
logger.debug("Limits API request successful.")
|
308
|
+
logger.trace("Response body: %s", data)
|
163
309
|
return json.loads(data)
|
164
|
-
|
165
|
-
|
166
|
-
|
310
|
+
|
311
|
+
logger.error("Limits API request failed: %s %s", response.status, response.reason)
|
312
|
+
logger.debug("Response body: %s", data)
|
313
|
+
|
314
|
+
except Exception as err:
|
315
|
+
logger.exception("Error during limits request: %s", err)
|
316
|
+
|
317
|
+
finally:
|
318
|
+
conn.close()
|
319
|
+
|
320
|
+
return None
|
321
|
+
|
322
|
+
def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
|
323
|
+
"""
|
324
|
+
Execute a SOQL query using the REST or Tooling API.
|
325
|
+
|
326
|
+
:param query: The SOQL query string.
|
327
|
+
:param tooling: If True, use the Tooling API endpoint.
|
328
|
+
:return: Parsed JSON response or None on failure.
|
329
|
+
"""
|
330
|
+
self._refresh_token_if_needed()
|
331
|
+
|
332
|
+
if not self.access_token:
|
333
|
+
logger.error("No access token available for query.")
|
334
|
+
return None
|
335
|
+
|
336
|
+
endpoint = f"/services/data/{self.api_version}/"
|
337
|
+
endpoint += "tooling/query" if tooling else "query"
|
338
|
+
query_string = f"?q={quote(query)}"
|
339
|
+
|
340
|
+
endpoint += query_string
|
341
|
+
|
342
|
+
headers = {
|
343
|
+
"Authorization": f"Bearer {self.access_token}",
|
344
|
+
"User-Agent": self.user_agent,
|
345
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
346
|
+
"Accept": "application/json",
|
347
|
+
}
|
348
|
+
|
349
|
+
parsed_url = urlparse(self.instance_url)
|
350
|
+
conn = self._create_connection(parsed_url.netloc)
|
351
|
+
|
352
|
+
try:
|
353
|
+
paginated_results = {"totalSize": 0, "done": False, "records": []}
|
354
|
+
while True:
|
355
|
+
logger.trace("Request endpoint: %s", endpoint)
|
356
|
+
logger.trace("Request headers: %s", headers)
|
357
|
+
conn.request("GET", endpoint, headers=headers)
|
358
|
+
response = conn.getresponse()
|
359
|
+
data = response.read().decode("utf-8")
|
360
|
+
self._http_resp_header_logic(response)
|
361
|
+
|
362
|
+
if response.status == 200:
|
363
|
+
current_results = json.loads(data)
|
364
|
+
paginated_results["records"].extend(current_results["records"])
|
365
|
+
query_done = current_results.get("done")
|
366
|
+
if query_done:
|
367
|
+
total_size = current_results.get("totalSize")
|
368
|
+
paginated_results = {
|
369
|
+
"totalSize": total_size,
|
370
|
+
"done": query_done,
|
371
|
+
"records": paginated_results["records"],
|
372
|
+
}
|
373
|
+
logger.debug(
|
374
|
+
"Query successful, returned %s records: %r",
|
375
|
+
total_size,
|
376
|
+
query,
|
377
|
+
)
|
378
|
+
logger.trace("Query full response: %s", data)
|
379
|
+
break
|
380
|
+
endpoint = current_results.get("nextRecordsUrl")
|
381
|
+
logger.debug(
|
382
|
+
"Query batch successful, getting next batch: %s", endpoint
|
383
|
+
)
|
384
|
+
else:
|
385
|
+
logger.debug("Query failed: %r", query)
|
386
|
+
logger.error(
|
387
|
+
"Query failed with HTTP status %s (%s)",
|
388
|
+
response.status,
|
389
|
+
response.reason,
|
390
|
+
)
|
391
|
+
logger.debug("Query response: %s", data)
|
392
|
+
break
|
393
|
+
|
394
|
+
return paginated_results
|
395
|
+
|
167
396
|
except Exception as err:
|
168
|
-
|
397
|
+
logger.exception("Exception during query: %s", err)
|
398
|
+
|
169
399
|
finally:
|
170
400
|
conn.close()
|
171
401
|
|
@@ -1,20 +1,17 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: sfq
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.8
|
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
|
7
7
|
Classifier: Development Status :: 3 - Alpha
|
8
8
|
Classifier: Intended Audience :: Developers
|
9
|
-
Classifier: Programming Language :: Python :: 3.7
|
10
|
-
Classifier: Programming Language :: Python :: 3.8
|
11
9
|
Classifier: Programming Language :: Python :: 3.9
|
12
10
|
Classifier: Programming Language :: Python :: 3.10
|
13
11
|
Classifier: Programming Language :: Python :: 3.12
|
14
12
|
Classifier: Programming Language :: Python :: 3.13
|
15
13
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
16
|
-
Requires-Python: >=3.
|
17
|
-
Requires-Dist: prompt-toolkit>=3.0.3
|
14
|
+
Requires-Python: >=3.9
|
18
15
|
Description-Content-Type: text/markdown
|
19
16
|
|
20
17
|
# sfq (Salesforce Query)
|
@@ -39,24 +36,6 @@ pip install sfq
|
|
39
36
|
|
40
37
|
## Usage
|
41
38
|
|
42
|
-
### Interactive Querying
|
43
|
-
|
44
|
-
```powershell
|
45
|
-
usage: python -m sfq [-a SFDXAUTHURL] [--dry-run] [--disable-fuzzy-completion]
|
46
|
-
|
47
|
-
Interactively query Salesforce data with real-time autocompletion.
|
48
|
-
|
49
|
-
options:
|
50
|
-
-h, --help show this help message and exit
|
51
|
-
-a, --sfdxAuthUrl SFDXAUTHURL
|
52
|
-
Salesforce auth url
|
53
|
-
--dry-run Print the query without executing it
|
54
|
-
--disable-fuzzy-completion
|
55
|
-
Disable fuzzy completion
|
56
|
-
```
|
57
|
-
|
58
|
-
You can run the `sfq` library in interactive mode by passing the `-a` option with the `SFDX_AUTH_URL` argument or by setting the `SFDX_AUTH_URL` environment variable.
|
59
|
-
|
60
39
|
### Library Querying
|
61
40
|
|
62
41
|
```python
|
@@ -114,7 +93,7 @@ To use the `sfq` library, you'll need a **client ID** and **refresh token**. The
|
|
114
93
|
"status": 0,
|
115
94
|
"result": {
|
116
95
|
"id": "00Daa0000000000000",
|
117
|
-
"apiVersion": "
|
96
|
+
"apiVersion": "63.0",
|
118
97
|
"accessToken": "your-access-token-here",
|
119
98
|
"instanceUrl": "https://example-dev-ed.trailblaze.my.salesforce.com",
|
120
99
|
"username": "user@example.com",
|
@@ -146,7 +125,9 @@ To use the `sfq` library, you'll need a **client ID** and **refresh token**. The
|
|
146
125
|
pip install sfq && python -c "from sfq import SFAuth; sf = SFAuth(instance_url='$instanceUrl', client_id='$clientId', refresh_token='$refreshToken'); print(sf.query('$query'))" | jq -r '.records[].Id'
|
147
126
|
```
|
148
127
|
|
149
|
-
##
|
128
|
+
## Important Considerations
|
129
|
+
|
130
|
+
- **Security**: Safeguard your refresh token diligently, as it provides access to your Salesforce environment. Avoid sharing or exposing it in unsecured locations.
|
131
|
+
- **Efficient Data Retrieval**: The `query` function automatically handles pagination, simplifying record retrieval across large datasets. It's recommended to use the `LIMIT` clause in queries to control the volume of data returned.
|
132
|
+
- **Advanced Metadata Queries**: Utilize the `tooling=True` option within the `query` function to access the Salesforce Tooling API. This option is designed for performing complex metadata operations, enhancing your data management capabilities.
|
150
133
|
|
151
|
-
- **Authentication**: Make sure your refresh token is kept secure, as it grants access to your Salesforce instance.
|
152
|
-
- **Tooling API**: You can set the `tooling=True` argument in the `query` method to access the Salesforce Tooling API for more advanced metadata queries. This is limited to library usage only.
|
@@ -0,0 +1,5 @@
|
|
1
|
+
sfq/__init__.py,sha256=MlnlaJCHz4NgL_nudfa15Aman7LdgEtv-hY3G-M731E,15203
|
2
|
+
sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
sfq-0.0.8.dist-info/METADATA,sha256=I1iwzWgydgwKKslNoZvc5y0VjPKytm1R5B1wLXm7VoY,5066
|
4
|
+
sfq-0.0.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
5
|
+
sfq-0.0.8.dist-info/RECORD,,
|
sfq/__main__.py
DELETED
@@ -1,151 +0,0 @@
|
|
1
|
-
import difflib
|
2
|
-
import http.client
|
3
|
-
import json
|
4
|
-
|
5
|
-
from sfq import SFAuth
|
6
|
-
|
7
|
-
from prompt_toolkit import prompt
|
8
|
-
from prompt_toolkit.completion import Completer, Completion
|
9
|
-
|
10
|
-
def _interactive_shell(sf: SFAuth, dry_run: bool, disable_fuzzy_completion: bool):
|
11
|
-
"""Runs an interactive REPL for querying Salesforce data with real-time autocompletion."""
|
12
|
-
|
13
|
-
sobject = None
|
14
|
-
fields = None
|
15
|
-
|
16
|
-
class DynamicSeparatorCompleter(Completer):
|
17
|
-
"""Custom completer that adapts to different separators."""
|
18
|
-
|
19
|
-
def __init__(self, words: list[str], separators: list[str] = [","]):
|
20
|
-
self.words = words
|
21
|
-
self.separators = separators
|
22
|
-
|
23
|
-
def get_completions(self, document, complete_event):
|
24
|
-
text_before_cursor = document.text_before_cursor
|
25
|
-
|
26
|
-
for separator in self.separators:
|
27
|
-
if separator in text_before_cursor:
|
28
|
-
last_token = text_before_cursor.split(separator)[-1].strip()
|
29
|
-
break
|
30
|
-
else:
|
31
|
-
last_token = text_before_cursor.strip()
|
32
|
-
|
33
|
-
|
34
|
-
matches_difflib = difflib.get_close_matches(last_token, self.words, n=20, cutoff=0.6)
|
35
|
-
matches_starting = [word for word in self.words if word.lower().startswith(last_token.lower())]
|
36
|
-
|
37
|
-
if not disable_fuzzy_completion and (len(last_token) > 6 or not(matches_starting)):
|
38
|
-
matches = matches_difflib
|
39
|
-
else:
|
40
|
-
matches = matches_starting
|
41
|
-
|
42
|
-
for word in matches:
|
43
|
-
yield Completion(word, start_position=-len(last_token))
|
44
|
-
|
45
|
-
def _get_objects(sf: SFAuth):
|
46
|
-
"""Retrieve available Salesforce objects."""
|
47
|
-
host = sf.instance_url.split("://")[1].split("/")[0]
|
48
|
-
conn = http.client.HTTPSConnection(host)
|
49
|
-
uri = f"/services/data/{sf.api_version}/sobjects/"
|
50
|
-
headers = {'Authorization': f'Bearer {sf._refresh_token_if_needed()}'}
|
51
|
-
conn.request("GET", uri, headers=headers)
|
52
|
-
response = conn.getresponse()
|
53
|
-
|
54
|
-
if response.status != 200:
|
55
|
-
print(f'Error: {response.status} {response.reason}')
|
56
|
-
return []
|
57
|
-
|
58
|
-
data = json.loads(response.read())
|
59
|
-
return [sobject['name'] for sobject in data['sobjects']]
|
60
|
-
|
61
|
-
def _get_fields(sobject: str, sf: SFAuth):
|
62
|
-
"""Retrieve available fields for a given Salesforce object."""
|
63
|
-
host = sf.instance_url.split("://")[1].split("/")[0]
|
64
|
-
conn = http.client.HTTPSConnection(host)
|
65
|
-
uri = f"/services/data/{sf.api_version}/sobjects/{sobject}/describe/"
|
66
|
-
headers = {'Authorization': f'Bearer {sf._refresh_token_if_needed()}'}
|
67
|
-
conn.request("GET", uri, headers=headers)
|
68
|
-
response = conn.getresponse()
|
69
|
-
|
70
|
-
if response.status != 200:
|
71
|
-
print(f'Error: {response.status} {response.reason}')
|
72
|
-
raise ValueError(f'Unable to fetch fields for sObject "{sobject}": {response.status}, {response.reason}')
|
73
|
-
|
74
|
-
data = json.loads(response.read())
|
75
|
-
return [f['name'] for f in data['fields']]
|
76
|
-
|
77
|
-
available_objects = _get_objects(sf)
|
78
|
-
|
79
|
-
object_completer = DynamicSeparatorCompleter(available_objects)
|
80
|
-
while not sobject:
|
81
|
-
sobject = prompt('FROM ', completer=object_completer).strip()
|
82
|
-
|
83
|
-
|
84
|
-
available_fields = _get_fields(sobject, sf)
|
85
|
-
field_completer = DynamicSeparatorCompleter(available_fields, separators=[","])
|
86
|
-
while not fields:
|
87
|
-
fields = prompt('SELECT ', completer=field_completer).strip()
|
88
|
-
|
89
|
-
where_completer = DynamicSeparatorCompleter(available_fields, separators=[" AND ", " OR "])
|
90
|
-
where = prompt("WHERE ", completer=where_completer).strip()
|
91
|
-
where_clause = f"WHERE {where}" if where else ""
|
92
|
-
|
93
|
-
limit = prompt("LIMIT ", default="200").strip()
|
94
|
-
limit_clause = f"LIMIT {limit}" if limit else ""
|
95
|
-
|
96
|
-
query = f"SELECT {fields} FROM {sobject} {where_clause} {limit_clause}".replace(' ', ' ')
|
97
|
-
|
98
|
-
if dry_run:
|
99
|
-
print('\nDry-run, skipping execution...')
|
100
|
-
print(f'\nQuery: {query}\n')
|
101
|
-
return query
|
102
|
-
|
103
|
-
print('\nExecuting query...\n')
|
104
|
-
data = sf.query(query)
|
105
|
-
print(json.dumps(data, indent=4))
|
106
|
-
print(f'\nQuery: {query}\n')
|
107
|
-
return data
|
108
|
-
|
109
|
-
if __name__ == "__main__":
|
110
|
-
import argparse
|
111
|
-
import os
|
112
|
-
|
113
|
-
parser = argparse.ArgumentParser(
|
114
|
-
description='Interactively query Salesforce data with real-time autocompletion.'
|
115
|
-
)
|
116
|
-
parser.add_argument(
|
117
|
-
'-a', '--sfdxAuthUrl', type=str, help='Salesforce auth url', default=os.environ.get('SFDX_AUTH_URL')
|
118
|
-
)
|
119
|
-
parser.add_argument(
|
120
|
-
'--dry-run', action='store_true', help='Print the query without executing it', default=str(os.environ.get('SFQ_DRY_RUN')),
|
121
|
-
)
|
122
|
-
parser.add_argument(
|
123
|
-
'--disable-fuzzy-completion', action='store_true', help='Disable fuzzy completion', default=str(os.environ.get('SFQ_DISABLE_FUZZY_COMPLETION')),
|
124
|
-
)
|
125
|
-
args = parser.parse_args()
|
126
|
-
|
127
|
-
if not args.sfdxAuthUrl:
|
128
|
-
raise ValueError('SFDX_AUTH_URL environment variable is not set nor provided as an argument')
|
129
|
-
|
130
|
-
try:
|
131
|
-
if args.dry_run.lower() not in ['true', '1']:
|
132
|
-
args.dry_run = False
|
133
|
-
except AttributeError:
|
134
|
-
pass
|
135
|
-
|
136
|
-
try:
|
137
|
-
if args.disable_fuzzy_completion.lower() not in ['true', '1']:
|
138
|
-
args.disable_fuzzy_completion = False
|
139
|
-
except AttributeError:
|
140
|
-
pass
|
141
|
-
|
142
|
-
|
143
|
-
_interactive_shell(
|
144
|
-
SFAuth(
|
145
|
-
instance_url=f"https://{str(args.sfdxAuthUrl).split('@')[1]}",
|
146
|
-
client_id=str(args.sfdxAuthUrl).split('//')[1].split('::')[0],
|
147
|
-
refresh_token=str(args.sfdxAuthUrl).split('::')[1].split('@')[0],
|
148
|
-
),
|
149
|
-
args.dry_run,
|
150
|
-
args.disable_fuzzy_completion
|
151
|
-
)
|
sfq-0.0.6.dist-info/RECORD
DELETED
@@ -1,6 +0,0 @@
|
|
1
|
-
sfq/__init__.py,sha256=V28VeAC89wFw-7DpXajKvAki50bo0yd4CbMWt84APNM,6466
|
2
|
-
sfq/__main__.py,sha256=XDb-hRo7jkB3nbHJkQCxL0eUx2Sgl_C3Rr_peuOd6fs,5724
|
3
|
-
sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
-
sfq-0.0.6.dist-info/METADATA,sha256=ab6qxYGmin-YwwxKQU76Ww4JMajhkPw01My8ymxLjIk,5491
|
5
|
-
sfq-0.0.6.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
|
6
|
-
sfq-0.0.6.dist-info/RECORD,,
|