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/_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
+ )