sfq 0.0.32__py3-none-any.whl → 0.0.33__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 +223 -896
- sfq/_cometd.py +7 -10
- sfq/auth.py +401 -0
- sfq/crud.py +446 -0
- sfq/exceptions.py +54 -0
- sfq/http_client.py +319 -0
- sfq/query.py +398 -0
- sfq/soap.py +181 -0
- sfq/utils.py +196 -0
- {sfq-0.0.32.dist-info → sfq-0.0.33.dist-info}/METADATA +1 -1
- sfq-0.0.33.dist-info/RECORD +13 -0
- sfq-0.0.32.dist-info/RECORD +0 -6
- {sfq-0.0.32.dist-info → sfq-0.0.33.dist-info}/WHEEL +0 -0
sfq/_cometd.py
CHANGED
@@ -2,13 +2,14 @@ import http.client
|
|
2
2
|
import json
|
3
3
|
import logging
|
4
4
|
import time
|
5
|
-
from typing import Any, Optional
|
6
5
|
import warnings
|
7
6
|
from queue import Empty, Queue
|
7
|
+
from typing import Any, Optional
|
8
8
|
|
9
9
|
TRACE = 5
|
10
10
|
logging.addLevelName(TRACE, "TRACE")
|
11
11
|
|
12
|
+
|
12
13
|
class ExperimentalWarning(Warning):
|
13
14
|
pass
|
14
15
|
|
@@ -69,6 +70,7 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
|
|
69
70
|
logging.Logger.trace = trace
|
70
71
|
logger = logging.getLogger("sfq")
|
71
72
|
|
73
|
+
|
72
74
|
def _reconnect_with_backoff(self, attempt: int) -> None:
|
73
75
|
wait_time = min(2**attempt, 60)
|
74
76
|
logger.warning(
|
@@ -76,6 +78,7 @@ def _reconnect_with_backoff(self, attempt: int) -> None:
|
|
76
78
|
)
|
77
79
|
time.sleep(wait_time)
|
78
80
|
|
81
|
+
|
79
82
|
def _subscribe_topic(
|
80
83
|
self,
|
81
84
|
topic: str,
|
@@ -141,9 +144,7 @@ def _subscribe_topic(
|
|
141
144
|
logger.trace("Received handshake response.")
|
142
145
|
for name, value in response.getheaders():
|
143
146
|
if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
|
144
|
-
_bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
|
145
|
-
";"
|
146
|
-
)[0]
|
147
|
+
_bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(";")[0]
|
147
148
|
headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
|
148
149
|
break
|
149
150
|
|
@@ -232,9 +233,7 @@ def _subscribe_topic(
|
|
232
233
|
ConnectionRefusedError,
|
233
234
|
ConnectionError,
|
234
235
|
) as e:
|
235
|
-
logger.warning(
|
236
|
-
f"Connection error (attempt {attempt + 1}): {e}"
|
237
|
-
)
|
236
|
+
logger.warning(f"Connection error (attempt {attempt + 1}): {e}")
|
238
237
|
conn.close()
|
239
238
|
conn = self._create_connection(parsed_url.netloc)
|
240
239
|
self._reconnect_with_backoff(attempt)
|
@@ -266,9 +265,7 @@ def _subscribe_topic(
|
|
266
265
|
finally:
|
267
266
|
if client_id:
|
268
267
|
try:
|
269
|
-
logger.trace(
|
270
|
-
f"Disconnecting from server with client ID: {client_id}"
|
271
|
-
)
|
268
|
+
logger.trace(f"Disconnecting from server with client ID: {client_id}")
|
272
269
|
disconnect_payload = json.dumps(
|
273
270
|
[
|
274
271
|
{
|
sfq/auth.py
ADDED
@@ -0,0 +1,401 @@
|
|
1
|
+
"""
|
2
|
+
Authentication module for the SFQ library.
|
3
|
+
|
4
|
+
This module handles OAuth token management, refresh logic, instance URL formatting,
|
5
|
+
and proxy configuration for Salesforce API authentication.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import os
|
9
|
+
import time
|
10
|
+
from typing import Any, Dict, Optional, Tuple
|
11
|
+
from urllib.parse import quote, urlparse
|
12
|
+
|
13
|
+
from .exceptions import AuthenticationError, ConfigurationError
|
14
|
+
from .utils import get_logger
|
15
|
+
|
16
|
+
logger = get_logger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
class AuthManager:
|
20
|
+
"""
|
21
|
+
Manages OAuth authentication for Salesforce API access.
|
22
|
+
|
23
|
+
This class handles token refresh, expiration checking, instance URL formatting,
|
24
|
+
and proxy configuration. It encapsulates all authentication-related logic
|
25
|
+
that was previously embedded in the main SFAuth class.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(
|
29
|
+
self,
|
30
|
+
instance_url: str,
|
31
|
+
client_id: str,
|
32
|
+
refresh_token: str,
|
33
|
+
client_secret: str,
|
34
|
+
api_version: str = "v64.0",
|
35
|
+
token_endpoint: str = "/services/oauth2/token",
|
36
|
+
access_token: Optional[str] = None,
|
37
|
+
token_expiration_time: Optional[float] = None,
|
38
|
+
token_lifetime: int = 15 * 60,
|
39
|
+
proxy: str = "_auto",
|
40
|
+
) -> None:
|
41
|
+
"""
|
42
|
+
Initialize the AuthManager with OAuth parameters.
|
43
|
+
|
44
|
+
:param instance_url: The Salesforce instance URL
|
45
|
+
:param client_id: The OAuth client ID
|
46
|
+
:param refresh_token: The OAuth refresh token
|
47
|
+
:param client_secret: The OAuth client secret
|
48
|
+
:param api_version: The Salesforce API version
|
49
|
+
:param token_endpoint: The token endpoint path
|
50
|
+
:param access_token: Current access token (if available)
|
51
|
+
:param token_expiration_time: Token expiration timestamp
|
52
|
+
:param token_lifetime: Token lifetime in seconds
|
53
|
+
:param proxy: Proxy configuration
|
54
|
+
"""
|
55
|
+
self.instance_url = self._format_instance_url(instance_url)
|
56
|
+
self.client_id = client_id
|
57
|
+
self.client_secret = client_secret
|
58
|
+
self.refresh_token = refresh_token
|
59
|
+
self.api_version = api_version
|
60
|
+
self.token_endpoint = token_endpoint
|
61
|
+
self.access_token = access_token
|
62
|
+
self.token_expiration_time = token_expiration_time
|
63
|
+
self.token_lifetime = token_lifetime
|
64
|
+
|
65
|
+
# Initialize proxy configuration
|
66
|
+
self._configure_proxy(proxy)
|
67
|
+
|
68
|
+
# Initialize org and user IDs (set during token refresh)
|
69
|
+
self.org_id: Optional[str] = None
|
70
|
+
self.user_id: Optional[str] = None
|
71
|
+
|
72
|
+
def _format_instance_url(self, instance_url: str) -> str:
|
73
|
+
"""
|
74
|
+
Format the instance URL to ensure HTTPS protocol.
|
75
|
+
|
76
|
+
HTTPS is mandatory with Spring '21 release. This method ensures
|
77
|
+
that the instance URL is formatted correctly.
|
78
|
+
|
79
|
+
:param instance_url: The Salesforce instance URL
|
80
|
+
:return: The formatted instance URL with HTTPS
|
81
|
+
"""
|
82
|
+
if instance_url.startswith("https://"):
|
83
|
+
return instance_url
|
84
|
+
if instance_url.startswith("http://"):
|
85
|
+
return instance_url.replace("http://", "https://")
|
86
|
+
return f"https://{instance_url}"
|
87
|
+
|
88
|
+
def _configure_proxy(self, proxy: str) -> None:
|
89
|
+
"""
|
90
|
+
Configure the proxy based on the environment or provided value.
|
91
|
+
|
92
|
+
:param proxy: Proxy configuration ("_auto" for environment detection)
|
93
|
+
"""
|
94
|
+
if proxy == "_auto":
|
95
|
+
self.proxy = os.environ.get("https_proxy") # HTTPS is mandatory
|
96
|
+
if self.proxy:
|
97
|
+
logger.debug("Auto-configured proxy: %s", self.proxy)
|
98
|
+
else:
|
99
|
+
self.proxy = proxy
|
100
|
+
if self.proxy:
|
101
|
+
logger.debug("Using configured proxy: %s", self.proxy)
|
102
|
+
|
103
|
+
def get_proxy_config(self) -> Optional[str]:
|
104
|
+
"""
|
105
|
+
Get the current proxy configuration.
|
106
|
+
|
107
|
+
:return: Proxy URL or None if no proxy is configured
|
108
|
+
"""
|
109
|
+
return self.proxy
|
110
|
+
|
111
|
+
def validate_proxy_config(self) -> bool:
|
112
|
+
"""
|
113
|
+
Validate the current proxy configuration.
|
114
|
+
|
115
|
+
:return: True if proxy config is valid or None, False if invalid
|
116
|
+
"""
|
117
|
+
if not self.proxy:
|
118
|
+
return True # No proxy is valid
|
119
|
+
|
120
|
+
try:
|
121
|
+
parsed = urlparse(self.proxy)
|
122
|
+
return (
|
123
|
+
parsed.scheme in ("http", "https")
|
124
|
+
and parsed.netloc
|
125
|
+
and bool(parsed.hostname)
|
126
|
+
)
|
127
|
+
except Exception as e:
|
128
|
+
logger.error("Proxy validation failed: %s", e)
|
129
|
+
return False
|
130
|
+
|
131
|
+
def get_proxy_netloc(self) -> Optional[str]:
|
132
|
+
"""
|
133
|
+
Get the network location (host:port) from the proxy URL.
|
134
|
+
|
135
|
+
:return: Network location string or None if no proxy
|
136
|
+
:raises ConfigurationError: If proxy URL is invalid
|
137
|
+
"""
|
138
|
+
if not self.proxy:
|
139
|
+
return None
|
140
|
+
|
141
|
+
try:
|
142
|
+
parsed = urlparse(self.proxy)
|
143
|
+
if not parsed.netloc:
|
144
|
+
raise ConfigurationError(f"Invalid proxy URL: {self.proxy}")
|
145
|
+
return parsed.netloc
|
146
|
+
except Exception as e:
|
147
|
+
raise ConfigurationError(f"Failed to parse proxy URL: {e}")
|
148
|
+
|
149
|
+
def get_proxy_hostname_and_port(self) -> Optional[Tuple[str, Optional[int]]]:
|
150
|
+
"""
|
151
|
+
Get the hostname and port from the proxy URL.
|
152
|
+
|
153
|
+
:return: Tuple of (hostname, port) or None if no proxy
|
154
|
+
:raises ConfigurationError: If proxy URL is invalid
|
155
|
+
"""
|
156
|
+
if not self.proxy:
|
157
|
+
return None
|
158
|
+
|
159
|
+
try:
|
160
|
+
parsed = urlparse(self.proxy)
|
161
|
+
if not parsed.hostname:
|
162
|
+
raise ConfigurationError(f"Invalid proxy URL: {self.proxy}")
|
163
|
+
return parsed.hostname, parsed.port
|
164
|
+
except Exception as e:
|
165
|
+
raise ConfigurationError(f"Failed to parse proxy URL: {e}")
|
166
|
+
|
167
|
+
def validate_instance_url(self) -> bool:
|
168
|
+
"""
|
169
|
+
Validate that the instance URL is properly formatted.
|
170
|
+
|
171
|
+
:return: True if valid, False otherwise
|
172
|
+
"""
|
173
|
+
try:
|
174
|
+
parsed = urlparse(self.instance_url)
|
175
|
+
return (
|
176
|
+
parsed.scheme == "https"
|
177
|
+
and parsed.netloc
|
178
|
+
and ".salesforce.com" in parsed.netloc
|
179
|
+
)
|
180
|
+
except Exception as e:
|
181
|
+
logger.error("Instance URL validation failed: %s", e)
|
182
|
+
return False
|
183
|
+
|
184
|
+
def is_sandbox_instance(self) -> bool:
|
185
|
+
"""
|
186
|
+
Check if the instance URL points to a sandbox environment.
|
187
|
+
|
188
|
+
:return: True if sandbox, False if production or unknown
|
189
|
+
"""
|
190
|
+
try:
|
191
|
+
return ".sandbox." in self.instance_url or "--" in self.instance_url
|
192
|
+
except Exception:
|
193
|
+
return False
|
194
|
+
|
195
|
+
def get_instance_type(self) -> str:
|
196
|
+
"""
|
197
|
+
Determine the type of Salesforce instance.
|
198
|
+
|
199
|
+
:return: String indicating instance type ('production', 'sandbox', 'trailblazer', 'unknown')
|
200
|
+
"""
|
201
|
+
try:
|
202
|
+
if ".trailblazer." in self.instance_url:
|
203
|
+
return "trailblazer"
|
204
|
+
elif ".sandbox." in self.instance_url or "--" in self.instance_url:
|
205
|
+
return "sandbox"
|
206
|
+
elif ".my.salesforce.com" in self.instance_url:
|
207
|
+
return "production"
|
208
|
+
else:
|
209
|
+
return "unknown"
|
210
|
+
except Exception:
|
211
|
+
return "unknown"
|
212
|
+
|
213
|
+
def normalize_instance_url(self) -> str:
|
214
|
+
"""
|
215
|
+
Normalize the instance URL by removing trailing slashes and ensuring HTTPS.
|
216
|
+
|
217
|
+
:return: Normalized instance URL
|
218
|
+
"""
|
219
|
+
url = self._format_instance_url(self.instance_url)
|
220
|
+
return url.rstrip("/")
|
221
|
+
|
222
|
+
def get_base_domain(self) -> Optional[str]:
|
223
|
+
"""
|
224
|
+
Extract the base domain from the instance URL.
|
225
|
+
|
226
|
+
:return: Base domain or None if extraction fails
|
227
|
+
"""
|
228
|
+
try:
|
229
|
+
parsed = urlparse(self.instance_url)
|
230
|
+
if parsed.hostname:
|
231
|
+
# Extract the base domain (e.g., "my.salesforce.com" from "test.my.salesforce.com")
|
232
|
+
parts = parsed.hostname.split(".")
|
233
|
+
if len(parts) >= 3 and "salesforce.com" in parsed.hostname:
|
234
|
+
return ".".join(
|
235
|
+
parts[-3:]
|
236
|
+
) # Get last 3 parts for "my.salesforce.com"
|
237
|
+
return None
|
238
|
+
except Exception as e:
|
239
|
+
logger.error("Failed to extract base domain: %s", e)
|
240
|
+
return None
|
241
|
+
|
242
|
+
def is_token_expired(self) -> bool:
|
243
|
+
"""
|
244
|
+
Check if the access token has expired.
|
245
|
+
|
246
|
+
:return: True if token is expired or missing, False otherwise
|
247
|
+
"""
|
248
|
+
try:
|
249
|
+
return time.time() >= float(self.token_expiration_time)
|
250
|
+
except (TypeError, ValueError):
|
251
|
+
logger.warning("Token expiration check failed. Treating token as expired.")
|
252
|
+
return True
|
253
|
+
|
254
|
+
def _prepare_token_payload(self) -> Dict[str, Optional[str]]:
|
255
|
+
"""
|
256
|
+
Prepare the payload for the token request.
|
257
|
+
|
258
|
+
This method constructs a dictionary containing the necessary parameters
|
259
|
+
for a token request using the refresh token grant type.
|
260
|
+
|
261
|
+
:return: Dictionary containing the payload for the token request
|
262
|
+
"""
|
263
|
+
payload = {
|
264
|
+
"grant_type": "refresh_token",
|
265
|
+
"client_id": self.client_id,
|
266
|
+
"client_secret": self.client_secret,
|
267
|
+
"refresh_token": self.refresh_token,
|
268
|
+
}
|
269
|
+
|
270
|
+
# Remove empty client_secret
|
271
|
+
if not self.client_secret or self.client_secret == " ":
|
272
|
+
payload.pop("client_secret", None)
|
273
|
+
|
274
|
+
return payload
|
275
|
+
|
276
|
+
def process_token_response(self, token_data: Dict[str, Any]) -> bool:
|
277
|
+
"""
|
278
|
+
Process a successful token response and update internal state.
|
279
|
+
|
280
|
+
:param token_data: The token response data from Salesforce
|
281
|
+
:return: True if processing was successful, False otherwise
|
282
|
+
"""
|
283
|
+
try:
|
284
|
+
self.access_token = token_data.get("access_token")
|
285
|
+
issued_at = token_data.get("issued_at")
|
286
|
+
|
287
|
+
# Extract org and user IDs from the token response
|
288
|
+
token_id = token_data.get("id")
|
289
|
+
if token_id:
|
290
|
+
try:
|
291
|
+
from .utils import extract_org_and_user_ids
|
292
|
+
|
293
|
+
self.org_id, self.user_id = extract_org_and_user_ids(token_id)
|
294
|
+
logger.trace(
|
295
|
+
"Authenticated as user %s for org %s (%s)",
|
296
|
+
self.user_id,
|
297
|
+
self.org_id,
|
298
|
+
token_data.get("instance_url"),
|
299
|
+
)
|
300
|
+
except ValueError as e:
|
301
|
+
logger.error(
|
302
|
+
"Failed to extract org/user IDs from token response: %s", e
|
303
|
+
)
|
304
|
+
|
305
|
+
# Calculate token expiration time
|
306
|
+
if self.access_token and issued_at:
|
307
|
+
self.token_expiration_time = int(issued_at) + self.token_lifetime
|
308
|
+
logger.trace("New token expires at %s", self.token_expiration_time)
|
309
|
+
return True
|
310
|
+
|
311
|
+
return False
|
312
|
+
|
313
|
+
except Exception as e:
|
314
|
+
logger.error("Failed to process token response: %s", e)
|
315
|
+
return False
|
316
|
+
|
317
|
+
def get_auth_headers(self) -> Dict[str, str]:
|
318
|
+
"""
|
319
|
+
Generate authentication headers for API requests.
|
320
|
+
|
321
|
+
:return: Dictionary containing the Authorization header
|
322
|
+
:raises AuthenticationError: If no valid access token is available
|
323
|
+
"""
|
324
|
+
if not self.access_token:
|
325
|
+
raise AuthenticationError("No access token available")
|
326
|
+
|
327
|
+
return {
|
328
|
+
"Authorization": f"Bearer {self.access_token}",
|
329
|
+
}
|
330
|
+
|
331
|
+
def get_token_request_headers(self) -> Dict[str, str]:
|
332
|
+
"""
|
333
|
+
Generate headers for token refresh requests.
|
334
|
+
|
335
|
+
:return: Dictionary containing headers for token requests
|
336
|
+
"""
|
337
|
+
return {
|
338
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
339
|
+
}
|
340
|
+
|
341
|
+
def format_token_request_body(self, payload: Dict[str, str]) -> str:
|
342
|
+
"""
|
343
|
+
Format the token request payload as URL-encoded form data.
|
344
|
+
|
345
|
+
:param payload: The token request payload
|
346
|
+
:return: URL-encoded form data string
|
347
|
+
"""
|
348
|
+
return "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
|
349
|
+
|
350
|
+
def get_token_endpoint_url(self) -> str:
|
351
|
+
"""
|
352
|
+
Get the full URL for the token endpoint.
|
353
|
+
|
354
|
+
:return: Complete token endpoint URL
|
355
|
+
"""
|
356
|
+
return f"{self.instance_url}{self.token_endpoint}"
|
357
|
+
|
358
|
+
def needs_token_refresh(self) -> bool:
|
359
|
+
"""
|
360
|
+
Check if a token refresh is needed.
|
361
|
+
|
362
|
+
:return: True if token refresh is needed, False otherwise
|
363
|
+
"""
|
364
|
+
return not self.access_token or self.is_token_expired()
|
365
|
+
|
366
|
+
def clear_token(self) -> None:
|
367
|
+
"""
|
368
|
+
Clear the current access token and expiration time.
|
369
|
+
|
370
|
+
This method is useful for forcing a token refresh or handling
|
371
|
+
authentication failures.
|
372
|
+
"""
|
373
|
+
self.access_token = None
|
374
|
+
self.token_expiration_time = None
|
375
|
+
self.org_id = None
|
376
|
+
self.user_id = None
|
377
|
+
logger.debug("Access token cleared")
|
378
|
+
|
379
|
+
def get_instance_netloc(self) -> str:
|
380
|
+
"""
|
381
|
+
Get the network location (host:port) from the instance URL.
|
382
|
+
|
383
|
+
:return: Network location string
|
384
|
+
:raises ConfigurationError: If instance URL is invalid
|
385
|
+
"""
|
386
|
+
try:
|
387
|
+
parsed = urlparse(self.instance_url)
|
388
|
+
if not parsed.netloc:
|
389
|
+
raise ConfigurationError(f"Invalid instance URL: {self.instance_url}")
|
390
|
+
return parsed.netloc
|
391
|
+
except Exception as e:
|
392
|
+
raise ConfigurationError(f"Failed to parse instance URL: {e}")
|
393
|
+
|
394
|
+
def __repr__(self) -> str:
|
395
|
+
"""String representation of AuthManager for debugging."""
|
396
|
+
return (
|
397
|
+
f"AuthManager(instance_url='{self.instance_url}', "
|
398
|
+
f"client_id='{self.client_id}', "
|
399
|
+
f"has_token={bool(self.access_token)}, "
|
400
|
+
f"token_expired={self.is_token_expired() if self.access_token else 'N/A'})"
|
401
|
+
)
|