sfq 0.0.9__tar.gz → 0.0.11__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.
- {sfq-0.0.9 → sfq-0.0.11}/PKG-INFO +1 -1
- {sfq-0.0.9 → sfq-0.0.11}/pyproject.toml +1 -1
- sfq-0.0.11/src/sfq/__init__.py +831 -0
- {sfq-0.0.9 → sfq-0.0.11}/uv.lock +1 -1
- sfq-0.0.9/src/sfq/__init__.py +0 -398
- {sfq-0.0.9 → sfq-0.0.11}/.github/workflows/publish.yml +0 -0
- {sfq-0.0.9 → sfq-0.0.11}/.gitignore +0 -0
- {sfq-0.0.9 → sfq-0.0.11}/.python-version +0 -0
- {sfq-0.0.9 → sfq-0.0.11}/README.md +0 -0
- {sfq-0.0.9 → sfq-0.0.11}/src/sfq/py.typed +0 -0
@@ -0,0 +1,831 @@
|
|
1
|
+
import base64
|
2
|
+
import http.client
|
3
|
+
import json
|
4
|
+
import logging
|
5
|
+
import os
|
6
|
+
import time
|
7
|
+
import warnings
|
8
|
+
from queue import Empty, Queue
|
9
|
+
from typing import Any, Dict, Optional
|
10
|
+
from urllib.parse import quote, urlparse
|
11
|
+
|
12
|
+
TRACE = 5
|
13
|
+
logging.addLevelName(TRACE, "TRACE")
|
14
|
+
|
15
|
+
class ExperimentalWarning(Warning):
|
16
|
+
pass
|
17
|
+
|
18
|
+
def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
|
19
|
+
"""Custom TRACE level logging function with redaction."""
|
20
|
+
|
21
|
+
def _redact_sensitive(data: Any) -> Any:
|
22
|
+
"""Redacts sensitive keys from a dictionary or query string."""
|
23
|
+
REDACT_VALUE = "*" * 8
|
24
|
+
REDACT_KEYS = [
|
25
|
+
"access_token",
|
26
|
+
"authorization",
|
27
|
+
"set-cookie",
|
28
|
+
"cookie",
|
29
|
+
"refresh_token",
|
30
|
+
]
|
31
|
+
if isinstance(data, dict):
|
32
|
+
return {
|
33
|
+
k: (REDACT_VALUE if k.lower() in REDACT_KEYS else v)
|
34
|
+
for k, v in data.items()
|
35
|
+
}
|
36
|
+
elif isinstance(data, (list, tuple)):
|
37
|
+
return type(data)(
|
38
|
+
(
|
39
|
+
(item[0], REDACT_VALUE)
|
40
|
+
if isinstance(item, tuple) and item[0].lower() in REDACT_KEYS
|
41
|
+
else item
|
42
|
+
for item in data
|
43
|
+
)
|
44
|
+
)
|
45
|
+
elif isinstance(data, str):
|
46
|
+
parts = data.split("&")
|
47
|
+
for i, part in enumerate(parts):
|
48
|
+
if "=" in part:
|
49
|
+
key, value = part.split("=", 1)
|
50
|
+
if key.lower() in REDACT_KEYS:
|
51
|
+
parts[i] = f"{key}={REDACT_VALUE}"
|
52
|
+
return "&".join(parts)
|
53
|
+
return data
|
54
|
+
|
55
|
+
redacted_args = args
|
56
|
+
if args:
|
57
|
+
first = args[0]
|
58
|
+
if isinstance(first, str):
|
59
|
+
try:
|
60
|
+
loaded = json.loads(first)
|
61
|
+
first = loaded
|
62
|
+
except (json.JSONDecodeError, TypeError):
|
63
|
+
pass
|
64
|
+
redacted_first = _redact_sensitive(first)
|
65
|
+
redacted_args = (redacted_first,) + args[1:]
|
66
|
+
|
67
|
+
if self.isEnabledFor(TRACE):
|
68
|
+
self._log(TRACE, message, redacted_args, **kwargs)
|
69
|
+
|
70
|
+
|
71
|
+
logging.Logger.trace = trace
|
72
|
+
logger = logging.getLogger("sfq")
|
73
|
+
|
74
|
+
|
75
|
+
class SFAuth:
|
76
|
+
def __init__(
|
77
|
+
self,
|
78
|
+
instance_url: str,
|
79
|
+
client_id: str,
|
80
|
+
refresh_token: str,
|
81
|
+
api_version: str = "v63.0",
|
82
|
+
token_endpoint: str = "/services/oauth2/token",
|
83
|
+
access_token: Optional[str] = None,
|
84
|
+
token_expiration_time: Optional[float] = None,
|
85
|
+
token_lifetime: int = 15 * 60,
|
86
|
+
user_agent: str = "sfq/0.0.11",
|
87
|
+
proxy: str = "auto",
|
88
|
+
) -> None:
|
89
|
+
"""
|
90
|
+
Initializes the SFAuth with necessary parameters.
|
91
|
+
|
92
|
+
:param instance_url: The Salesforce instance URL.
|
93
|
+
:param client_id: The client ID for OAuth.
|
94
|
+
:param refresh_token: The refresh token for OAuth.
|
95
|
+
:param api_version: The Salesforce API version (default is "v63.0").
|
96
|
+
:param token_endpoint: The token endpoint (default is "/services/oauth2/token").
|
97
|
+
:param access_token: The access token for the current session (default is None).
|
98
|
+
:param token_expiration_time: The expiration time of the access token (default is None).
|
99
|
+
:param token_lifetime: The lifetime of the access token in seconds (default is 15 minutes).
|
100
|
+
:param user_agent: Custom User-Agent string (default is "sfq/0.0.11").
|
101
|
+
:param proxy: The proxy configuration, "auto" to use environment (default is "auto").
|
102
|
+
"""
|
103
|
+
self.instance_url = instance_url
|
104
|
+
self.client_id = client_id
|
105
|
+
self.refresh_token = refresh_token
|
106
|
+
self.api_version = api_version
|
107
|
+
self.token_endpoint = token_endpoint
|
108
|
+
self.access_token = access_token
|
109
|
+
self.token_expiration_time = token_expiration_time
|
110
|
+
self.token_lifetime = token_lifetime
|
111
|
+
self.user_agent = user_agent
|
112
|
+
self._auto_configure_proxy(proxy)
|
113
|
+
self._high_api_usage_threshold = 80
|
114
|
+
|
115
|
+
def _auto_configure_proxy(self, proxy: str) -> None:
|
116
|
+
"""
|
117
|
+
Automatically configure the proxy based on the environment or provided value.
|
118
|
+
"""
|
119
|
+
if proxy == "auto":
|
120
|
+
self.proxy = os.environ.get("https_proxy")
|
121
|
+
if self.proxy:
|
122
|
+
logger.debug("Auto-configured proxy: %s", self.proxy)
|
123
|
+
else:
|
124
|
+
self.proxy = proxy
|
125
|
+
logger.debug("Using configured proxy: %s", self.proxy)
|
126
|
+
|
127
|
+
def _prepare_payload(self) -> Dict[str, str]:
|
128
|
+
"""
|
129
|
+
Prepare the payload for the token request.
|
130
|
+
"""
|
131
|
+
return {
|
132
|
+
"grant_type": "refresh_token",
|
133
|
+
"client_id": self.client_id,
|
134
|
+
"refresh_token": self.refresh_token,
|
135
|
+
}
|
136
|
+
|
137
|
+
def _create_connection(self, netloc: str) -> http.client.HTTPConnection:
|
138
|
+
"""
|
139
|
+
Create a connection using HTTP or HTTPS, with optional proxy support.
|
140
|
+
|
141
|
+
:param netloc: The target host and port from the parsed instance URL.
|
142
|
+
:return: An HTTP(S)Connection object.
|
143
|
+
"""
|
144
|
+
if self.proxy:
|
145
|
+
proxy_url = urlparse(self.proxy)
|
146
|
+
logger.trace("Using proxy: %s", self.proxy)
|
147
|
+
conn = http.client.HTTPSConnection(proxy_url.hostname, proxy_url.port)
|
148
|
+
conn.set_tunnel(netloc)
|
149
|
+
logger.trace("Using proxy tunnel to %s", netloc)
|
150
|
+
else:
|
151
|
+
conn = http.client.HTTPSConnection(netloc)
|
152
|
+
logger.trace("Direct connection to %s", netloc)
|
153
|
+
return conn
|
154
|
+
|
155
|
+
def _new_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
156
|
+
"""
|
157
|
+
Send a POST request to the Salesforce token endpoint using http.client.
|
158
|
+
|
159
|
+
:param payload: Dictionary of form-encoded OAuth parameters.
|
160
|
+
:return: Parsed JSON response if successful, otherwise None.
|
161
|
+
"""
|
162
|
+
parsed_url = urlparse(self.instance_url)
|
163
|
+
conn = self._create_connection(parsed_url.netloc)
|
164
|
+
headers = {
|
165
|
+
"Accept": "application/json",
|
166
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
167
|
+
"User-Agent": self.user_agent,
|
168
|
+
}
|
169
|
+
body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
170
|
+
|
171
|
+
try:
|
172
|
+
logger.trace("Request endpoint: %s", self.token_endpoint)
|
173
|
+
logger.trace("Request body: %s", body)
|
174
|
+
logger.trace("Request headers: %s", headers)
|
175
|
+
conn.request("POST", self.token_endpoint, body, headers)
|
176
|
+
response = conn.getresponse()
|
177
|
+
data = response.read().decode("utf-8")
|
178
|
+
self._http_resp_header_logic(response)
|
179
|
+
|
180
|
+
if response.status == 200:
|
181
|
+
logger.trace("Token refresh successful.")
|
182
|
+
logger.trace("Response body: %s", data)
|
183
|
+
return json.loads(data)
|
184
|
+
|
185
|
+
logger.error(
|
186
|
+
"Token refresh failed: %s %s", response.status, response.reason
|
187
|
+
)
|
188
|
+
logger.debug("Response body: %s", data)
|
189
|
+
|
190
|
+
except Exception as err:
|
191
|
+
logger.exception("Error during token request: %s", err)
|
192
|
+
|
193
|
+
finally:
|
194
|
+
logger.trace("Closing connection.")
|
195
|
+
conn.close()
|
196
|
+
|
197
|
+
return None
|
198
|
+
|
199
|
+
def _http_resp_header_logic(self, response: http.client.HTTPResponse) -> None:
|
200
|
+
"""
|
201
|
+
Perform additional logic based on the HTTP response headers.
|
202
|
+
|
203
|
+
:param response: The HTTP response object.
|
204
|
+
:return: None
|
205
|
+
"""
|
206
|
+
logger.trace(
|
207
|
+
"Response status: %s, reason: %s", response.status, response.reason
|
208
|
+
)
|
209
|
+
headers = response.getheaders()
|
210
|
+
headers_list = [(k, v) for k, v in headers if not v.startswith("BrowserId=")]
|
211
|
+
logger.trace("Response headers: %s", headers_list)
|
212
|
+
for key, value in headers_list:
|
213
|
+
if key.startswith("Sforce-"):
|
214
|
+
if key == "Sforce-Limit-Info":
|
215
|
+
current_api_calls = int(value.split("=")[1].split("/")[0])
|
216
|
+
maximum_api_calls = int(value.split("=")[1].split("/")[1])
|
217
|
+
usage_percentage = round(
|
218
|
+
current_api_calls / maximum_api_calls * 100, 2
|
219
|
+
)
|
220
|
+
if usage_percentage > self._high_api_usage_threshold:
|
221
|
+
logger.warning(
|
222
|
+
"High API usage: %s/%s (%s%%)",
|
223
|
+
current_api_calls,
|
224
|
+
maximum_api_calls,
|
225
|
+
usage_percentage,
|
226
|
+
)
|
227
|
+
else:
|
228
|
+
logger.debug(
|
229
|
+
"API usage: %s/%s (%s%%)",
|
230
|
+
current_api_calls,
|
231
|
+
maximum_api_calls,
|
232
|
+
usage_percentage,
|
233
|
+
)
|
234
|
+
|
235
|
+
def _refresh_token_if_needed(self) -> Optional[str]:
|
236
|
+
"""
|
237
|
+
Automatically refresh the access token if it has expired or is missing.
|
238
|
+
|
239
|
+
:return: A valid access token or None if refresh failed.
|
240
|
+
"""
|
241
|
+
if self.access_token and not self._is_token_expired():
|
242
|
+
return self.access_token
|
243
|
+
|
244
|
+
logger.trace("Access token expired or missing, refreshing...")
|
245
|
+
payload = self._prepare_payload()
|
246
|
+
token_data = self._new_token_request(payload)
|
247
|
+
|
248
|
+
if token_data:
|
249
|
+
self.access_token = token_data.get("access_token")
|
250
|
+
issued_at = token_data.get("issued_at")
|
251
|
+
|
252
|
+
try:
|
253
|
+
self.org_id = token_data.get("id").split("/")[4]
|
254
|
+
self.user_id = token_data.get("id").split("/")[5]
|
255
|
+
logger.trace(
|
256
|
+
"Authenticated as user %s for org %s (%s)",
|
257
|
+
self.user_id,
|
258
|
+
self.org_id,
|
259
|
+
token_data.get("instance_url"),
|
260
|
+
)
|
261
|
+
except (IndexError, KeyError):
|
262
|
+
logger.error("Failed to extract org/user IDs from token response.")
|
263
|
+
|
264
|
+
if self.access_token and issued_at:
|
265
|
+
self.token_expiration_time = int(issued_at) + self.token_lifetime
|
266
|
+
logger.trace("New token expires at %s", self.token_expiration_time)
|
267
|
+
return self.access_token
|
268
|
+
|
269
|
+
logger.error("Failed to obtain access token.")
|
270
|
+
return None
|
271
|
+
|
272
|
+
def _is_token_expired(self) -> bool:
|
273
|
+
"""
|
274
|
+
Check if the access token has expired.
|
275
|
+
|
276
|
+
:return: True if token is expired or missing, False otherwise.
|
277
|
+
"""
|
278
|
+
try:
|
279
|
+
return time.time() >= float(self.token_expiration_time)
|
280
|
+
except (TypeError, ValueError):
|
281
|
+
logger.warning("Token expiration check failed. Treating token as expired.")
|
282
|
+
return True
|
283
|
+
|
284
|
+
def read_static_resource_name(
|
285
|
+
self, resource_name: str, namespace: Optional[str] = None
|
286
|
+
) -> Optional[str]:
|
287
|
+
"""
|
288
|
+
Read a static resource for a given name from the Salesforce instance.
|
289
|
+
|
290
|
+
:param resource_name: Name of the static resource to read.
|
291
|
+
:param namespace: Namespace of the static resource to read (default is None).
|
292
|
+
:return: Static resource content or None on failure.
|
293
|
+
"""
|
294
|
+
_safe_resource_name = quote(resource_name, safe="")
|
295
|
+
query = f"SELECT Id FROM StaticResource WHERE Name = '{_safe_resource_name}'"
|
296
|
+
if namespace:
|
297
|
+
query += f" AND NamespacePrefix = '{namespace}'"
|
298
|
+
query += " LIMIT 1"
|
299
|
+
_static_resource_id_response = self.query(query)
|
300
|
+
|
301
|
+
if (
|
302
|
+
_static_resource_id_response
|
303
|
+
and _static_resource_id_response.get("records")
|
304
|
+
and len(_static_resource_id_response["records"]) > 0
|
305
|
+
):
|
306
|
+
return self.read_static_resource_id(
|
307
|
+
_static_resource_id_response["records"][0].get("Id")
|
308
|
+
)
|
309
|
+
|
310
|
+
logger.error(f"Failed to read static resource with name {_safe_resource_name}.")
|
311
|
+
return None
|
312
|
+
|
313
|
+
def read_static_resource_id(self, resource_id: str) -> Optional[str]:
|
314
|
+
"""
|
315
|
+
Read a static resource for a given ID from the Salesforce instance.
|
316
|
+
|
317
|
+
:param resource_id: ID of the static resource to read.
|
318
|
+
:return: Static resource content or None on failure.
|
319
|
+
"""
|
320
|
+
self._refresh_token_if_needed()
|
321
|
+
|
322
|
+
if not self.access_token:
|
323
|
+
logger.error("No access token available for limits.")
|
324
|
+
return None
|
325
|
+
|
326
|
+
endpoint = f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}/Body"
|
327
|
+
headers = {
|
328
|
+
"Authorization": f"Bearer {self.access_token}",
|
329
|
+
"User-Agent": self.user_agent,
|
330
|
+
"Accept": "application/json",
|
331
|
+
}
|
332
|
+
|
333
|
+
parsed_url = urlparse(self.instance_url)
|
334
|
+
conn = self._create_connection(parsed_url.netloc)
|
335
|
+
|
336
|
+
try:
|
337
|
+
logger.trace("Request endpoint: %s", endpoint)
|
338
|
+
logger.trace("Request headers: %s", headers)
|
339
|
+
conn.request("GET", endpoint, headers=headers)
|
340
|
+
response = conn.getresponse()
|
341
|
+
data = response.read().decode("utf-8")
|
342
|
+
self._http_resp_header_logic(response)
|
343
|
+
|
344
|
+
if response.status == 200:
|
345
|
+
logger.debug("Get Static Resource Body API request successful.")
|
346
|
+
logger.trace("Response body: %s", data)
|
347
|
+
return data
|
348
|
+
|
349
|
+
logger.error(
|
350
|
+
"Get Static Resource Body API request failed: %s %s",
|
351
|
+
response.status,
|
352
|
+
response.reason,
|
353
|
+
)
|
354
|
+
logger.debug("Response body: %s", data)
|
355
|
+
|
356
|
+
except Exception as err:
|
357
|
+
logger.exception(
|
358
|
+
"Error during Get Static Resource Body API request: %s", err
|
359
|
+
)
|
360
|
+
|
361
|
+
finally:
|
362
|
+
logger.trace("Closing connection...")
|
363
|
+
conn.close()
|
364
|
+
|
365
|
+
return None
|
366
|
+
|
367
|
+
def update_static_resource_name(
|
368
|
+
self, resource_name: str, data: str, namespace: Optional[str] = None
|
369
|
+
) -> Optional[Dict[str, Any]]:
|
370
|
+
"""
|
371
|
+
Update a static resource for a given name in the Salesforce instance.
|
372
|
+
|
373
|
+
:param resource_name: Name of the static resource to update.
|
374
|
+
:param data: Content to update the static resource with.
|
375
|
+
:param namespace: Optional namespace to search for the static resource.
|
376
|
+
:return: Static resource content or None on failure.
|
377
|
+
"""
|
378
|
+
safe_resource_name = quote(resource_name, safe="")
|
379
|
+
query = f"SELECT Id FROM StaticResource WHERE Name = '{safe_resource_name}'"
|
380
|
+
if namespace:
|
381
|
+
query += f" AND NamespacePrefix = '{namespace}'"
|
382
|
+
query += " LIMIT 1"
|
383
|
+
|
384
|
+
static_resource_id_response = self.query(query)
|
385
|
+
|
386
|
+
if (
|
387
|
+
static_resource_id_response
|
388
|
+
and static_resource_id_response.get("records")
|
389
|
+
and len(static_resource_id_response["records"]) > 0
|
390
|
+
):
|
391
|
+
return self.update_static_resource_id(
|
392
|
+
static_resource_id_response["records"][0].get("Id"), data
|
393
|
+
)
|
394
|
+
|
395
|
+
logger.error(
|
396
|
+
f"Failed to update static resource with name {safe_resource_name}."
|
397
|
+
)
|
398
|
+
return None
|
399
|
+
|
400
|
+
def update_static_resource_id(
|
401
|
+
self, resource_id: str, data: str
|
402
|
+
) -> Optional[Dict[str, Any]]:
|
403
|
+
"""
|
404
|
+
Replace the content of a static resource in the Salesforce instance by ID.
|
405
|
+
|
406
|
+
:param resource_id: ID of the static resource to update.
|
407
|
+
:param data: Content to update the static resource with.
|
408
|
+
:return: Parsed JSON response or None on failure.
|
409
|
+
"""
|
410
|
+
self._refresh_token_if_needed()
|
411
|
+
|
412
|
+
if not self.access_token:
|
413
|
+
logger.error("No access token available for limits.")
|
414
|
+
return None
|
415
|
+
|
416
|
+
payload = {"Body": base64.b64encode(data.encode("utf-8"))}
|
417
|
+
|
418
|
+
endpoint = (
|
419
|
+
f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
|
420
|
+
)
|
421
|
+
headers = {
|
422
|
+
"Authorization": f"Bearer {self.access_token}",
|
423
|
+
"User-Agent": self.user_agent,
|
424
|
+
"Content-Type": "application/json",
|
425
|
+
"Accept": "application/json",
|
426
|
+
}
|
427
|
+
|
428
|
+
parsed_url = urlparse(self.instance_url)
|
429
|
+
conn = self._create_connection(parsed_url.netloc)
|
430
|
+
|
431
|
+
try:
|
432
|
+
logger.trace("Request endpoint: %s", endpoint)
|
433
|
+
logger.trace("Request headers: %s", headers)
|
434
|
+
logger.trace("Request payload: %s", payload)
|
435
|
+
conn.request(
|
436
|
+
"PATCH",
|
437
|
+
endpoint,
|
438
|
+
headers=headers,
|
439
|
+
body=json.dumps(payload, default=lambda x: x.decode("utf-8")),
|
440
|
+
)
|
441
|
+
response = conn.getresponse()
|
442
|
+
data = response.read().decode("utf-8")
|
443
|
+
self._http_resp_header_logic(response)
|
444
|
+
|
445
|
+
if response.status == 200:
|
446
|
+
logger.debug("Patch Static Resource request successful.")
|
447
|
+
logger.trace("Response body: %s", data)
|
448
|
+
return json.loads(data)
|
449
|
+
|
450
|
+
logger.error(
|
451
|
+
"Patch Static Resource API request failed: %s %s",
|
452
|
+
response.status,
|
453
|
+
response.reason,
|
454
|
+
)
|
455
|
+
logger.debug("Response body: %s", data)
|
456
|
+
|
457
|
+
except Exception as err:
|
458
|
+
logger.exception("Error during patch request: %s", err)
|
459
|
+
|
460
|
+
finally:
|
461
|
+
logger.trace("Closing connection.")
|
462
|
+
conn.close()
|
463
|
+
|
464
|
+
return None
|
465
|
+
|
466
|
+
def limits(self) -> Optional[Dict[str, Any]]:
|
467
|
+
"""
|
468
|
+
Execute a GET request to the Salesforce Limits API.
|
469
|
+
|
470
|
+
:return: Parsed JSON response or None on failure.
|
471
|
+
"""
|
472
|
+
self._refresh_token_if_needed()
|
473
|
+
|
474
|
+
if not self.access_token:
|
475
|
+
logger.error("No access token available for limits.")
|
476
|
+
return None
|
477
|
+
|
478
|
+
endpoint = f"/services/data/{self.api_version}/limits"
|
479
|
+
headers = {
|
480
|
+
"Authorization": f"Bearer {self.access_token}",
|
481
|
+
"User-Agent": self.user_agent,
|
482
|
+
"Accept": "application/json",
|
483
|
+
}
|
484
|
+
|
485
|
+
parsed_url = urlparse(self.instance_url)
|
486
|
+
conn = self._create_connection(parsed_url.netloc)
|
487
|
+
|
488
|
+
try:
|
489
|
+
logger.trace("Request endpoint: %s", endpoint)
|
490
|
+
logger.trace("Request headers: %s", headers)
|
491
|
+
conn.request("GET", endpoint, headers=headers)
|
492
|
+
response = conn.getresponse()
|
493
|
+
data = response.read().decode("utf-8")
|
494
|
+
self._http_resp_header_logic(response)
|
495
|
+
|
496
|
+
if response.status == 200:
|
497
|
+
logger.debug("Limits API request successful.")
|
498
|
+
logger.trace("Response body: %s", data)
|
499
|
+
return json.loads(data)
|
500
|
+
|
501
|
+
logger.error(
|
502
|
+
"Limits API request failed: %s %s", response.status, response.reason
|
503
|
+
)
|
504
|
+
logger.debug("Response body: %s", data)
|
505
|
+
|
506
|
+
except Exception as err:
|
507
|
+
logger.exception("Error during limits request: %s", err)
|
508
|
+
|
509
|
+
finally:
|
510
|
+
logger.debug("Closing connection...")
|
511
|
+
conn.close()
|
512
|
+
|
513
|
+
return None
|
514
|
+
|
515
|
+
def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
|
516
|
+
"""
|
517
|
+
Execute a SOQL query using the REST or Tooling API.
|
518
|
+
|
519
|
+
:param query: The SOQL query string.
|
520
|
+
:param tooling: If True, use the Tooling API endpoint.
|
521
|
+
:return: Parsed JSON response or None on failure.
|
522
|
+
"""
|
523
|
+
self._refresh_token_if_needed()
|
524
|
+
|
525
|
+
if not self.access_token:
|
526
|
+
logger.error("No access token available for query.")
|
527
|
+
return None
|
528
|
+
|
529
|
+
endpoint = f"/services/data/{self.api_version}/"
|
530
|
+
endpoint += "tooling/query" if tooling else "query"
|
531
|
+
query_string = f"?q={quote(query)}"
|
532
|
+
|
533
|
+
endpoint += query_string
|
534
|
+
|
535
|
+
headers = {
|
536
|
+
"Authorization": f"Bearer {self.access_token}",
|
537
|
+
"User-Agent": self.user_agent,
|
538
|
+
"Accept": "application/json",
|
539
|
+
}
|
540
|
+
|
541
|
+
parsed_url = urlparse(self.instance_url)
|
542
|
+
conn = self._create_connection(parsed_url.netloc)
|
543
|
+
|
544
|
+
try:
|
545
|
+
paginated_results = {"totalSize": 0, "done": False, "records": []}
|
546
|
+
while True:
|
547
|
+
logger.trace("Request endpoint: %s", endpoint)
|
548
|
+
logger.trace("Request headers: %s", headers)
|
549
|
+
conn.request("GET", endpoint, headers=headers)
|
550
|
+
response = conn.getresponse()
|
551
|
+
data = response.read().decode("utf-8")
|
552
|
+
self._http_resp_header_logic(response)
|
553
|
+
|
554
|
+
if response.status == 200:
|
555
|
+
current_results = json.loads(data)
|
556
|
+
paginated_results["records"].extend(current_results["records"])
|
557
|
+
query_done = current_results.get("done")
|
558
|
+
if query_done:
|
559
|
+
total_size = current_results.get("totalSize")
|
560
|
+
paginated_results = {
|
561
|
+
"totalSize": total_size,
|
562
|
+
"done": query_done,
|
563
|
+
"records": paginated_results["records"],
|
564
|
+
}
|
565
|
+
logger.debug(
|
566
|
+
"Query successful, returned %s records: %r",
|
567
|
+
total_size,
|
568
|
+
query,
|
569
|
+
)
|
570
|
+
logger.trace("Query full response: %s", data)
|
571
|
+
break
|
572
|
+
endpoint = current_results.get("nextRecordsUrl")
|
573
|
+
logger.debug(
|
574
|
+
"Query batch successful, getting next batch: %s", endpoint
|
575
|
+
)
|
576
|
+
else:
|
577
|
+
logger.debug("Query failed: %r", query)
|
578
|
+
logger.error(
|
579
|
+
"Query failed with HTTP status %s (%s)",
|
580
|
+
response.status,
|
581
|
+
response.reason,
|
582
|
+
)
|
583
|
+
logger.debug("Query response: %s", data)
|
584
|
+
break
|
585
|
+
|
586
|
+
return paginated_results
|
587
|
+
|
588
|
+
except Exception as err:
|
589
|
+
logger.exception("Exception during query: %s", err)
|
590
|
+
|
591
|
+
finally:
|
592
|
+
logger.trace("Closing connection...")
|
593
|
+
conn.close()
|
594
|
+
|
595
|
+
return None
|
596
|
+
|
597
|
+
def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
|
598
|
+
"""
|
599
|
+
Execute a SOQL query using the Tooling API.
|
600
|
+
|
601
|
+
:param query: The SOQL query string.
|
602
|
+
:return: Parsed JSON response or None on failure.
|
603
|
+
"""
|
604
|
+
return self.query(query, tooling=True)
|
605
|
+
|
606
|
+
def _reconnect_with_backoff(self, attempt: int) -> None:
|
607
|
+
wait_time = min(2**attempt, 60)
|
608
|
+
logger.warning(
|
609
|
+
f"Reconnecting after failure, backoff {wait_time}s (attempt {attempt})"
|
610
|
+
)
|
611
|
+
time.sleep(wait_time)
|
612
|
+
self._refresh_token_if_needed()
|
613
|
+
|
614
|
+
def _subscribe_topic(
|
615
|
+
self,
|
616
|
+
topic: str,
|
617
|
+
queue_timeout: int = 90,
|
618
|
+
max_runtime: Optional[int] = None,
|
619
|
+
):
|
620
|
+
"""
|
621
|
+
Yields events from a subscribed Salesforce CometD topic.
|
622
|
+
|
623
|
+
:param topic: Topic to subscribe to, e.g. '/event/MyEvent__e'
|
624
|
+
:param queue_timeout: Seconds to wait for a message before logging heartbeat
|
625
|
+
:param max_runtime: Max total time to listen in seconds (None = unlimited)
|
626
|
+
"""
|
627
|
+
warnings.warn(
|
628
|
+
"The _subscribe_topic method is experimental and subject to change in future versions.",
|
629
|
+
ExperimentalWarning,
|
630
|
+
stacklevel=2,
|
631
|
+
)
|
632
|
+
|
633
|
+
self._refresh_token_if_needed()
|
634
|
+
self._msg_count: int = 0
|
635
|
+
|
636
|
+
if not self.access_token:
|
637
|
+
logger.error("No access token available for event stream.")
|
638
|
+
return
|
639
|
+
|
640
|
+
start_time = time.time()
|
641
|
+
message_queue = Queue()
|
642
|
+
headers = {
|
643
|
+
"Authorization": f"Bearer {self.access_token}",
|
644
|
+
"Content-Type": "application/json",
|
645
|
+
"Accept": "application/json",
|
646
|
+
"User-Agent": self.user_agent,
|
647
|
+
}
|
648
|
+
|
649
|
+
parsed_url = urlparse(self.instance_url)
|
650
|
+
conn = self._create_connection(parsed_url.netloc)
|
651
|
+
_API_VERSION = str(self.api_version).removeprefix("v")
|
652
|
+
client_id = str()
|
653
|
+
|
654
|
+
try:
|
655
|
+
logger.trace("Starting handshake with Salesforce CometD server.")
|
656
|
+
handshake_payload = json.dumps(
|
657
|
+
{
|
658
|
+
"id": str(self._msg_count + 1),
|
659
|
+
"version": "1.0",
|
660
|
+
"minimumVersion": "1.0",
|
661
|
+
"channel": "/meta/handshake",
|
662
|
+
"supportedConnectionTypes": ["long-polling"],
|
663
|
+
"advice": {"timeout": 60000, "interval": 0},
|
664
|
+
}
|
665
|
+
)
|
666
|
+
conn.request(
|
667
|
+
"POST",
|
668
|
+
f"/cometd/{_API_VERSION}/meta/handshake",
|
669
|
+
headers=headers,
|
670
|
+
body=handshake_payload,
|
671
|
+
)
|
672
|
+
response = conn.getresponse()
|
673
|
+
self._http_resp_header_logic(response)
|
674
|
+
|
675
|
+
logger.trace("Received handshake response.")
|
676
|
+
for name, value in response.getheaders():
|
677
|
+
if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
|
678
|
+
_bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
|
679
|
+
";"
|
680
|
+
)[0]
|
681
|
+
headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
|
682
|
+
break
|
683
|
+
|
684
|
+
data = json.loads(response.read().decode("utf-8"))
|
685
|
+
if not data or not data[0].get("successful"):
|
686
|
+
logger.error("Handshake failed: %s", data)
|
687
|
+
return
|
688
|
+
|
689
|
+
client_id = data[0]["clientId"]
|
690
|
+
logger.trace(f"Handshake successful, client ID: {client_id}")
|
691
|
+
|
692
|
+
logger.trace(f"Subscribing to topic: {topic}")
|
693
|
+
subscribe_message = {
|
694
|
+
"channel": "/meta/subscribe",
|
695
|
+
"clientId": client_id,
|
696
|
+
"subscription": topic,
|
697
|
+
"id": str(self._msg_count + 1),
|
698
|
+
}
|
699
|
+
conn.request(
|
700
|
+
"POST",
|
701
|
+
f"/cometd/{_API_VERSION}/meta/subscribe",
|
702
|
+
headers=headers,
|
703
|
+
body=json.dumps(subscribe_message),
|
704
|
+
)
|
705
|
+
response = conn.getresponse()
|
706
|
+
self._http_resp_header_logic(response)
|
707
|
+
|
708
|
+
sub_response = json.loads(response.read().decode("utf-8"))
|
709
|
+
if not sub_response or not sub_response[0].get("successful"):
|
710
|
+
logger.error("Subscription failed: %s", sub_response)
|
711
|
+
return
|
712
|
+
|
713
|
+
logger.info(f"Successfully subscribed to topic: {topic}")
|
714
|
+
logger.trace("Entering event polling loop.")
|
715
|
+
|
716
|
+
try:
|
717
|
+
while True:
|
718
|
+
if max_runtime and (time.time() - start_time > max_runtime):
|
719
|
+
logger.info(
|
720
|
+
f"Disconnecting after max_runtime={max_runtime} seconds"
|
721
|
+
)
|
722
|
+
break
|
723
|
+
|
724
|
+
logger.trace("Sending connection message.")
|
725
|
+
connect_payload = json.dumps(
|
726
|
+
[
|
727
|
+
{
|
728
|
+
"channel": "/meta/connect",
|
729
|
+
"clientId": client_id,
|
730
|
+
"connectionType": "long-polling",
|
731
|
+
"id": str(self._msg_count + 1),
|
732
|
+
}
|
733
|
+
]
|
734
|
+
)
|
735
|
+
|
736
|
+
max_retries = 5
|
737
|
+
attempt = 0
|
738
|
+
|
739
|
+
while attempt < max_retries:
|
740
|
+
try:
|
741
|
+
conn.request(
|
742
|
+
"POST",
|
743
|
+
f"/cometd/{_API_VERSION}/meta/connect",
|
744
|
+
headers=headers,
|
745
|
+
body=connect_payload,
|
746
|
+
)
|
747
|
+
response = conn.getresponse()
|
748
|
+
self._http_resp_header_logic(response)
|
749
|
+
self._msg_count += 1
|
750
|
+
|
751
|
+
events = json.loads(response.read().decode("utf-8"))
|
752
|
+
for event in events:
|
753
|
+
if event.get("channel") == topic and "data" in event:
|
754
|
+
logger.trace(
|
755
|
+
f"Event received for topic {topic}, data: {event['data']}"
|
756
|
+
)
|
757
|
+
message_queue.put(event)
|
758
|
+
break
|
759
|
+
except (
|
760
|
+
http.client.RemoteDisconnected,
|
761
|
+
ConnectionResetError,
|
762
|
+
TimeoutError,
|
763
|
+
http.client.BadStatusLine,
|
764
|
+
http.client.CannotSendRequest,
|
765
|
+
ConnectionAbortedError,
|
766
|
+
ConnectionRefusedError,
|
767
|
+
ConnectionError,
|
768
|
+
) as e:
|
769
|
+
logger.warning(
|
770
|
+
f"Connection error (attempt {attempt + 1}): {e}"
|
771
|
+
)
|
772
|
+
conn.close()
|
773
|
+
conn = self._create_connection(parsed_url.netloc)
|
774
|
+
self._reconnect_with_backoff(attempt)
|
775
|
+
attempt += 1
|
776
|
+
except Exception as e:
|
777
|
+
logger.exception(
|
778
|
+
f"Connection error (attempt {attempt + 1}): {e}"
|
779
|
+
)
|
780
|
+
break
|
781
|
+
else:
|
782
|
+
logger.error("Max retries reached. Exiting event stream.")
|
783
|
+
break
|
784
|
+
|
785
|
+
while True:
|
786
|
+
try:
|
787
|
+
msg = message_queue.get(timeout=queue_timeout, block=True)
|
788
|
+
yield msg
|
789
|
+
except Empty:
|
790
|
+
logger.debug(
|
791
|
+
f"Heartbeat: no message in last {queue_timeout} seconds"
|
792
|
+
)
|
793
|
+
break
|
794
|
+
except KeyboardInterrupt:
|
795
|
+
logger.info("Received keyboard interrupt, disconnecting...")
|
796
|
+
|
797
|
+
except Exception as e:
|
798
|
+
logger.exception(f"Polling error: {e}")
|
799
|
+
|
800
|
+
finally:
|
801
|
+
if client_id:
|
802
|
+
try:
|
803
|
+
logger.trace(
|
804
|
+
f"Disconnecting from server with client ID: {client_id}"
|
805
|
+
)
|
806
|
+
disconnect_payload = json.dumps(
|
807
|
+
[
|
808
|
+
{
|
809
|
+
"channel": "/meta/disconnect",
|
810
|
+
"clientId": client_id,
|
811
|
+
"id": str(self._msg_count + 1),
|
812
|
+
}
|
813
|
+
]
|
814
|
+
)
|
815
|
+
conn.request(
|
816
|
+
"POST",
|
817
|
+
f"/cometd/{_API_VERSION}/meta/disconnect",
|
818
|
+
headers=headers,
|
819
|
+
body=disconnect_payload,
|
820
|
+
)
|
821
|
+
response = conn.getresponse()
|
822
|
+
self._http_resp_header_logic(response)
|
823
|
+
_ = response.read()
|
824
|
+
logger.trace("Disconnected successfully.")
|
825
|
+
except Exception as e:
|
826
|
+
logger.warning(f"Exception during disconnect: {e}")
|
827
|
+
if conn:
|
828
|
+
logger.trace("Closing connection.")
|
829
|
+
conn.close()
|
830
|
+
|
831
|
+
logger.trace("Leaving event polling loop.")
|
{sfq-0.0.9 → sfq-0.0.11}/uv.lock
RENAMED
sfq-0.0.9/src/sfq/__init__.py
DELETED
@@ -1,398 +0,0 @@
|
|
1
|
-
import http.client
|
2
|
-
import json
|
3
|
-
import logging
|
4
|
-
import os
|
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")
|
60
|
-
|
61
|
-
|
62
|
-
class SFAuth:
|
63
|
-
def __init__(
|
64
|
-
self,
|
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.9",
|
74
|
-
proxy: str = "auto",
|
75
|
-
) -> None:
|
76
|
-
"""
|
77
|
-
Initializes the SFAuth with necessary parameters.
|
78
|
-
|
79
|
-
:param instance_url: The Salesforce instance URL.
|
80
|
-
:param client_id: The client ID for OAuth.
|
81
|
-
:param refresh_token: The refresh token for OAuth.
|
82
|
-
:param api_version: The Salesforce API version (default is "v63.0").
|
83
|
-
:param token_endpoint: The token endpoint (default is "/services/oauth2/token").
|
84
|
-
:param access_token: The access token for the current session (default is None).
|
85
|
-
:param token_expiration_time: The expiration time of the access token (default is None).
|
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.9").
|
88
|
-
:param proxy: The proxy configuration, "auto" to use environment (default is "auto").
|
89
|
-
"""
|
90
|
-
self.instance_url = instance_url
|
91
|
-
self.client_id = client_id
|
92
|
-
self.refresh_token = refresh_token
|
93
|
-
self.api_version = api_version
|
94
|
-
self.token_endpoint = token_endpoint
|
95
|
-
self.access_token = access_token
|
96
|
-
self.token_expiration_time = token_expiration_time
|
97
|
-
self.token_lifetime = token_lifetime
|
98
|
-
self.user_agent = user_agent
|
99
|
-
self._auto_configure_proxy(proxy)
|
100
|
-
self._high_api_usage_threshold = 80
|
101
|
-
|
102
|
-
def _auto_configure_proxy(self, proxy: str) -> None:
|
103
|
-
"""
|
104
|
-
Automatically configure the proxy based on the environment or provided value.
|
105
|
-
"""
|
106
|
-
if proxy == "auto":
|
107
|
-
self.proxy = os.environ.get("https_proxy")
|
108
|
-
if self.proxy:
|
109
|
-
logger.debug("Auto-configured proxy: %s", self.proxy)
|
110
|
-
else:
|
111
|
-
self.proxy = proxy
|
112
|
-
logger.debug("Using configured proxy: %s", self.proxy)
|
113
|
-
|
114
|
-
def _prepare_payload(self) -> Dict[str, str]:
|
115
|
-
"""
|
116
|
-
Prepare the payload for the token request.
|
117
|
-
"""
|
118
|
-
return {
|
119
|
-
"grant_type": "refresh_token",
|
120
|
-
"client_id": self.client_id,
|
121
|
-
"refresh_token": self.refresh_token,
|
122
|
-
}
|
123
|
-
|
124
|
-
def _create_connection(self, netloc: str) -> http.client.HTTPConnection:
|
125
|
-
"""
|
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.
|
130
|
-
"""
|
131
|
-
if self.proxy:
|
132
|
-
proxy_url = urlparse(self.proxy)
|
133
|
-
logger.trace("Using proxy: %s", self.proxy)
|
134
|
-
conn = http.client.HTTPSConnection(proxy_url.hostname, proxy_url.port)
|
135
|
-
conn.set_tunnel(netloc)
|
136
|
-
logger.trace("Using proxy tunnel to %s", netloc)
|
137
|
-
else:
|
138
|
-
conn = http.client.HTTPSConnection(netloc)
|
139
|
-
logger.trace("Direct connection to %s", netloc)
|
140
|
-
return conn
|
141
|
-
|
142
|
-
def _post_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
143
|
-
"""
|
144
|
-
Send a POST request to the Salesforce token endpoint using http.client.
|
145
|
-
|
146
|
-
:param payload: Dictionary of form-encoded OAuth parameters.
|
147
|
-
:return: Parsed JSON response if successful, otherwise None.
|
148
|
-
"""
|
149
|
-
parsed_url = urlparse(self.instance_url)
|
150
|
-
conn = self._create_connection(parsed_url.netloc)
|
151
|
-
headers = {
|
152
|
-
"Accept": "application/json",
|
153
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
154
|
-
"User-Agent": self.user_agent,
|
155
|
-
}
|
156
|
-
body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
157
|
-
|
158
|
-
try:
|
159
|
-
logger.trace("Request endpoint: %s", self.token_endpoint)
|
160
|
-
logger.trace("Request body: %s", body)
|
161
|
-
logger.trace("Request headers: %s", headers)
|
162
|
-
conn.request("POST", self.token_endpoint, body, headers)
|
163
|
-
response = conn.getresponse()
|
164
|
-
data = response.read().decode("utf-8")
|
165
|
-
self._http_resp_header_logic(response)
|
166
|
-
|
167
|
-
if response.status == 200:
|
168
|
-
logger.trace("Token refresh successful.")
|
169
|
-
logger.trace("Response body: %s", data)
|
170
|
-
return json.loads(data)
|
171
|
-
|
172
|
-
logger.error(
|
173
|
-
"Token refresh failed: %s %s", response.status, response.reason
|
174
|
-
)
|
175
|
-
logger.debug("Response body: %s", data)
|
176
|
-
|
177
|
-
except Exception as err:
|
178
|
-
logger.exception("Error during token request: %s", err)
|
179
|
-
|
180
|
-
finally:
|
181
|
-
conn.close()
|
182
|
-
|
183
|
-
return None
|
184
|
-
|
185
|
-
def _http_resp_header_logic(self, response: http.client.HTTPResponse) -> None:
|
186
|
-
"""
|
187
|
-
Perform additional logic based on the HTTP response headers.
|
188
|
-
|
189
|
-
:param response: The HTTP response object.
|
190
|
-
:return: None
|
191
|
-
"""
|
192
|
-
logger.trace(
|
193
|
-
"Response status: %s, reason: %s", response.status, response.reason
|
194
|
-
)
|
195
|
-
headers = response.getheaders()
|
196
|
-
headers_list = [(k, v) for k, v in headers if not v.startswith("BrowserId=")]
|
197
|
-
logger.trace("Response headers: %s", headers_list)
|
198
|
-
for key, value in headers_list:
|
199
|
-
if key.startswith("Sforce-"):
|
200
|
-
if key == "Sforce-Limit-Info":
|
201
|
-
current_api_calls = int(value.split("=")[1].split("/")[0])
|
202
|
-
maximum_api_calls = int(value.split("=")[1].split("/")[1])
|
203
|
-
usage_percentage = round(
|
204
|
-
current_api_calls / maximum_api_calls * 100, 2
|
205
|
-
)
|
206
|
-
if usage_percentage > self._high_api_usage_threshold:
|
207
|
-
logger.warning(
|
208
|
-
"High API usage: %s/%s (%s%%)",
|
209
|
-
current_api_calls,
|
210
|
-
maximum_api_calls,
|
211
|
-
usage_percentage,
|
212
|
-
)
|
213
|
-
else:
|
214
|
-
logger.debug(
|
215
|
-
"API usage: %s/%s (%s%%)",
|
216
|
-
current_api_calls,
|
217
|
-
maximum_api_calls,
|
218
|
-
usage_percentage,
|
219
|
-
)
|
220
|
-
|
221
|
-
def _refresh_token_if_needed(self) -> Optional[str]:
|
222
|
-
"""
|
223
|
-
Automatically refresh the access token if it has expired or is missing.
|
224
|
-
|
225
|
-
:return: A valid access token or None if refresh failed.
|
226
|
-
"""
|
227
|
-
if self.access_token and not self._is_token_expired():
|
228
|
-
return self.access_token
|
229
|
-
|
230
|
-
logger.trace("Access token expired or missing, refreshing...")
|
231
|
-
payload = self._prepare_payload()
|
232
|
-
token_data = self._post_token_request(payload)
|
233
|
-
|
234
|
-
if token_data:
|
235
|
-
self.access_token = token_data.get("access_token")
|
236
|
-
issued_at = token_data.get("issued_at")
|
237
|
-
|
238
|
-
try:
|
239
|
-
self.org_id = token_data.get("id").split("/")[4]
|
240
|
-
self.user_id = token_data.get("id").split("/")[5]
|
241
|
-
logger.trace(
|
242
|
-
"Authenticated as user %s in org %s", self.user_id, self.org_id
|
243
|
-
)
|
244
|
-
except (IndexError, KeyError):
|
245
|
-
logger.error("Failed to extract org/user IDs from token response.")
|
246
|
-
|
247
|
-
if self.access_token and issued_at:
|
248
|
-
self.token_expiration_time = int(issued_at) + self.token_lifetime
|
249
|
-
logger.trace("New token expires at %s", self.token_expiration_time)
|
250
|
-
return self.access_token
|
251
|
-
|
252
|
-
logger.error("Failed to obtain access token.")
|
253
|
-
return None
|
254
|
-
|
255
|
-
def _is_token_expired(self) -> bool:
|
256
|
-
"""
|
257
|
-
Check if the access token has expired.
|
258
|
-
|
259
|
-
:return: True if token is expired or missing, False otherwise.
|
260
|
-
"""
|
261
|
-
try:
|
262
|
-
return time.time() >= float(self.token_expiration_time)
|
263
|
-
except (TypeError, ValueError):
|
264
|
-
logger.warning("Token expiration check failed. Treating token as expired.")
|
265
|
-
return True
|
266
|
-
|
267
|
-
def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
|
268
|
-
"""
|
269
|
-
Execute a SOQL query using the Tooling API.
|
270
|
-
|
271
|
-
:param query: The SOQL query string.
|
272
|
-
:return: Parsed JSON response or None on failure.
|
273
|
-
"""
|
274
|
-
return self.query(query, tooling=True)
|
275
|
-
|
276
|
-
def limits(self) -> Optional[Dict[str, Any]]:
|
277
|
-
self._refresh_token_if_needed()
|
278
|
-
|
279
|
-
if not self.access_token:
|
280
|
-
logger.error("No access token available for limits.")
|
281
|
-
return None
|
282
|
-
|
283
|
-
endpoint = f"/services/data/{self.api_version}/limits"
|
284
|
-
headers = {
|
285
|
-
"Authorization": f"Bearer {self.access_token}",
|
286
|
-
"User-Agent": self.user_agent,
|
287
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
288
|
-
"Accept": "application/json",
|
289
|
-
}
|
290
|
-
|
291
|
-
parsed_url = urlparse(self.instance_url)
|
292
|
-
conn = self._create_connection(parsed_url.netloc)
|
293
|
-
|
294
|
-
try:
|
295
|
-
logger.trace("Request endpoint: %s", endpoint)
|
296
|
-
logger.trace("Request headers: %s", headers)
|
297
|
-
conn.request("GET", endpoint, headers=headers)
|
298
|
-
response = conn.getresponse()
|
299
|
-
data = response.read().decode("utf-8")
|
300
|
-
self._http_resp_header_logic(response)
|
301
|
-
|
302
|
-
if response.status == 200:
|
303
|
-
logger.debug("Limits API request successful.")
|
304
|
-
logger.trace("Response body: %s", data)
|
305
|
-
return json.loads(data)
|
306
|
-
|
307
|
-
logger.error("Limits API request failed: %s %s", response.status, response.reason)
|
308
|
-
logger.debug("Response body: %s", data)
|
309
|
-
|
310
|
-
except Exception as err:
|
311
|
-
logger.exception("Error during limits request: %s", err)
|
312
|
-
|
313
|
-
finally:
|
314
|
-
conn.close()
|
315
|
-
|
316
|
-
return None
|
317
|
-
|
318
|
-
def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
|
319
|
-
"""
|
320
|
-
Execute a SOQL query using the REST or Tooling API.
|
321
|
-
|
322
|
-
:param query: The SOQL query string.
|
323
|
-
:param tooling: If True, use the Tooling API endpoint.
|
324
|
-
:return: Parsed JSON response or None on failure.
|
325
|
-
"""
|
326
|
-
self._refresh_token_if_needed()
|
327
|
-
|
328
|
-
if not self.access_token:
|
329
|
-
logger.error("No access token available for query.")
|
330
|
-
return None
|
331
|
-
|
332
|
-
endpoint = f"/services/data/{self.api_version}/"
|
333
|
-
endpoint += "tooling/query" if tooling else "query"
|
334
|
-
query_string = f"?q={quote(query)}"
|
335
|
-
|
336
|
-
endpoint += query_string
|
337
|
-
|
338
|
-
headers = {
|
339
|
-
"Authorization": f"Bearer {self.access_token}",
|
340
|
-
"User-Agent": self.user_agent,
|
341
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
342
|
-
"Accept": "application/json",
|
343
|
-
}
|
344
|
-
|
345
|
-
parsed_url = urlparse(self.instance_url)
|
346
|
-
conn = self._create_connection(parsed_url.netloc)
|
347
|
-
|
348
|
-
try:
|
349
|
-
paginated_results = {"totalSize": 0, "done": False, "records": []}
|
350
|
-
while True:
|
351
|
-
logger.trace("Request endpoint: %s", endpoint)
|
352
|
-
logger.trace("Request headers: %s", headers)
|
353
|
-
conn.request("GET", endpoint, headers=headers)
|
354
|
-
response = conn.getresponse()
|
355
|
-
data = response.read().decode("utf-8")
|
356
|
-
self._http_resp_header_logic(response)
|
357
|
-
|
358
|
-
if response.status == 200:
|
359
|
-
current_results = json.loads(data)
|
360
|
-
paginated_results["records"].extend(current_results["records"])
|
361
|
-
query_done = current_results.get("done")
|
362
|
-
if query_done:
|
363
|
-
total_size = current_results.get("totalSize")
|
364
|
-
paginated_results = {
|
365
|
-
"totalSize": total_size,
|
366
|
-
"done": query_done,
|
367
|
-
"records": paginated_results["records"],
|
368
|
-
}
|
369
|
-
logger.debug(
|
370
|
-
"Query successful, returned %s records: %r",
|
371
|
-
total_size,
|
372
|
-
query,
|
373
|
-
)
|
374
|
-
logger.trace("Query full response: %s", data)
|
375
|
-
break
|
376
|
-
endpoint = current_results.get("nextRecordsUrl")
|
377
|
-
logger.debug(
|
378
|
-
"Query batch successful, getting next batch: %s", endpoint
|
379
|
-
)
|
380
|
-
else:
|
381
|
-
logger.debug("Query failed: %r", query)
|
382
|
-
logger.error(
|
383
|
-
"Query failed with HTTP status %s (%s)",
|
384
|
-
response.status,
|
385
|
-
response.reason,
|
386
|
-
)
|
387
|
-
logger.debug("Query response: %s", data)
|
388
|
-
break
|
389
|
-
|
390
|
-
return paginated_results
|
391
|
-
|
392
|
-
except Exception as err:
|
393
|
-
logger.exception("Exception during query: %s", err)
|
394
|
-
|
395
|
-
finally:
|
396
|
-
conn.close()
|
397
|
-
|
398
|
-
return None
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|