sfq 0.0.37__py3-none-any.whl → 0.0.39__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 CHANGED
@@ -18,6 +18,7 @@ from .exceptions import (
18
18
  CRUDError,
19
19
  HTTPError,
20
20
  QueryError,
21
+ QueryTimeoutError,
21
22
  SFQException,
22
23
  SOAPError,
23
24
  )
@@ -35,6 +36,7 @@ __all__ = [
35
36
  "AuthenticationError",
36
37
  "APIError",
37
38
  "QueryError",
39
+ "QueryTimeoutError",
38
40
  "CRUDError",
39
41
  "SOAPError",
40
42
  "HTTPError",
@@ -43,7 +45,7 @@ __all__ = [
43
45
  "__version__",
44
46
  ]
45
47
 
46
- __version__ = "0.0.37"
48
+ __version__ = "0.0.39"
47
49
  """
48
50
  ### `__version__`
49
51
 
@@ -67,7 +69,7 @@ class SFAuth:
67
69
  access_token: Optional[str] = None,
68
70
  token_expiration_time: Optional[float] = None,
69
71
  token_lifetime: int = 15 * 60,
70
- user_agent: str = "sfq/0.0.37",
72
+ user_agent: str = "sfq/0.0.39",
71
73
  sforce_client: str = "_auto",
72
74
  proxy: str = "_auto",
73
75
  ) -> None:
@@ -132,7 +134,7 @@ class SFAuth:
132
134
  self._debug_cleanup = DebugCleanup(sf_auth=self)
133
135
 
134
136
  # Store version information
135
- self.__version__ = "0.0.37"
137
+ self.__version__ = "0.0.39"
136
138
  """
137
139
  ### `__version__`
138
140
 
@@ -565,6 +567,7 @@ class SFAuth:
565
567
  def records_to_html_table(
566
568
  self,
567
569
  items: List[Dict[str, Any]],
570
+ headers: Dict[str, str] = None,
568
571
  styled: bool = False,
569
572
  ) -> str:
570
573
  """
@@ -576,4 +579,4 @@ class SFAuth:
576
579
  """
577
580
  if "records" in items:
578
581
  items = items["records"]
579
- return records_to_html_table(items, styled=styled)
582
+ return records_to_html_table(items, headers=headers, styled=styled)
sfq/exceptions.py CHANGED
@@ -30,6 +30,12 @@ class QueryError(APIError):
30
30
  pass
31
31
 
32
32
 
33
+ class QueryTimeoutError(QueryError):
34
+ """Raised when query operations timeout after all retry attempts."""
35
+
36
+ pass
37
+
38
+
33
39
  class CRUDError(APIError):
34
40
  """Raised when CRUD operations fail."""
35
41
 
sfq/http_client.py CHANGED
@@ -10,7 +10,8 @@ import json
10
10
  from typing import Dict, Optional, Tuple
11
11
 
12
12
  from .auth import AuthManager
13
- from .exceptions import ConfigurationError
13
+ from .exceptions import ConfigurationError, QueryTimeoutError
14
+ from .timeout_detector import TimeoutDetector
14
15
  from .utils import format_headers_for_logging, get_logger, log_api_usage
15
16
 
16
17
  logger = get_logger(__name__)
@@ -28,7 +29,7 @@ class HTTPClient:
28
29
  def __init__(
29
30
  self,
30
31
  auth_manager: AuthManager,
31
- user_agent: str = "sfq/0.0.37",
32
+ user_agent: str = "sfq/0.0.39",
32
33
  sforce_client: str = "_auto",
33
34
  high_api_usage_threshold: int = 80,
34
35
  ) -> None:
@@ -184,18 +185,134 @@ class HTTPClient:
184
185
  if key == "Sforce-Limit-Info":
185
186
  log_api_usage(value, self.high_api_usage_threshold)
186
187
 
187
- def send_authenticated_request(
188
+ def send_authenticated_request_with_retry(
188
189
  self,
189
190
  method: str,
190
191
  endpoint: str,
191
192
  body: Optional[str] = None,
192
193
  additional_headers: Optional[Dict[str, str]] = None,
194
+ max_retries: int = 3,
193
195
  ) -> Tuple[Optional[int], Optional[str]]:
194
196
  """
195
- Send an authenticated HTTP request with automatic token refresh.
197
+ Send an authenticated HTTP request with automatic timeout retry.
196
198
 
197
- This is a convenience method that handles common request patterns
198
- with authentication and standard headers.
199
+ This method wraps the existing send_authenticated_request with retry logic
200
+ that handles timeout errors by retrying up to max_retries times.
201
+
202
+ :param method: HTTP method
203
+ :param endpoint: API endpoint path
204
+ :param body: Optional request body
205
+ :param additional_headers: Optional additional headers to include
206
+ :param max_retries: Maximum number of retry attempts (default: 3)
207
+ :return: Tuple of (status_code, response_body) or (None, None) on failure
208
+ :raises QueryTimeoutError: When all retry attempts fail with timeout errors
209
+ """
210
+ # Validate retry count - negative values should be treated as 0
211
+ if max_retries < 0:
212
+ max_retries = 0
213
+
214
+ last_exception = None
215
+ request_context = f"{method} {endpoint}"
216
+
217
+ # Log initial request context for debugging
218
+ logger.trace("Starting request with retry capability: %s (max_retries=%d)",
219
+ request_context, max_retries)
220
+
221
+ for attempt in range(max_retries + 1): # +1 for initial attempt
222
+ try:
223
+ # Make the request using the original method
224
+ status, response_body = self._send_authenticated_request_internal(
225
+ method, endpoint, body, additional_headers
226
+ )
227
+
228
+ # Check if this is a timeout error
229
+ if TimeoutDetector.is_timeout_error(status, response_body, last_exception):
230
+ timeout_type = TimeoutDetector.get_timeout_type(status, response_body, last_exception)
231
+
232
+ if attempt < max_retries:
233
+ # Log detailed retry initiation with timeout type identification
234
+ logger.debug(
235
+ "Timeout detected (%s timeout) on attempt %d/%d for %s - "
236
+ "status_code=%s, retrying...",
237
+ timeout_type, attempt + 1, max_retries + 1, request_context, status
238
+ )
239
+ continue
240
+ else:
241
+ # All retries exhausted - log error before raising exception
242
+ logger.error(
243
+ "All %d retry attempts failed with timeout errors for %s - "
244
+ "final timeout type: %s, final status_code: %s",
245
+ max_retries + 1, request_context, timeout_type, status
246
+ )
247
+ raise QueryTimeoutError("QUERY_TIMEOUT")
248
+
249
+ # Not a timeout error or successful response, return immediately
250
+ if attempt > 0:
251
+ # Log successful retry recovery
252
+ logger.debug(
253
+ "Request succeeded on retry attempt %d/%d for %s - "
254
+ "status_code=%s, recovered from timeout",
255
+ attempt + 1, max_retries + 1, request_context, status
256
+ )
257
+ else:
258
+ # Log successful first attempt (trace level to avoid noise)
259
+ logger.trace("Request succeeded on first attempt for %s - status_code=%s",
260
+ request_context, status)
261
+
262
+ return status, response_body
263
+
264
+ except QueryTimeoutError:
265
+ # Re-raise QueryTimeoutError without logging (it was already logged)
266
+ raise
267
+ except Exception as e:
268
+ last_exception = e
269
+
270
+ # Check if this exception indicates a timeout
271
+ if TimeoutDetector.is_timeout_error(None, None, e):
272
+ timeout_type = TimeoutDetector.get_timeout_type(None, None, e)
273
+
274
+ if attempt < max_retries:
275
+ # Log detailed retry initiation with exception context
276
+ logger.debug(
277
+ "Timeout exception (%s timeout) on attempt %d/%d for %s - "
278
+ "exception: %s, retrying...",
279
+ timeout_type, attempt + 1, max_retries + 1, request_context,
280
+ type(e).__name__
281
+ )
282
+ continue
283
+ else:
284
+ # All retries exhausted - log error with exception details before raising
285
+ logger.error(
286
+ "All %d retry attempts failed with timeout errors for %s - "
287
+ "final timeout type: %s, final exception: %s",
288
+ max_retries + 1, request_context, timeout_type, type(e).__name__
289
+ )
290
+ raise QueryTimeoutError("QUERY_TIMEOUT")
291
+ else:
292
+ # Not a timeout exception - log and re-raise immediately
293
+ logger.debug(
294
+ "Non-timeout exception on attempt %d for %s - "
295
+ "exception: %s, not retrying",
296
+ attempt + 1, request_context, type(e).__name__
297
+ )
298
+ raise e
299
+
300
+ # This should never be reached, but just in case
301
+ logger.error("Unexpected code path reached in retry logic for %s", request_context)
302
+ raise QueryTimeoutError("QUERY_TIMEOUT")
303
+
304
+ def _send_authenticated_request_internal(
305
+ self,
306
+ method: str,
307
+ endpoint: str,
308
+ body: Optional[str] = None,
309
+ additional_headers: Optional[Dict[str, str]] = None,
310
+ ) -> Tuple[Optional[int], Optional[str]]:
311
+ """
312
+ Internal method for sending authenticated requests without retry logic.
313
+
314
+ This is the original send_authenticated_request logic extracted to avoid
315
+ recursion in the retry wrapper.
199
316
 
200
317
  :param method: HTTP method
201
318
  :param endpoint: API endpoint path
@@ -210,6 +327,32 @@ class HTTPClient:
210
327
 
211
328
  return self.send_request(method, endpoint, headers, body)
212
329
 
330
+ def send_authenticated_request(
331
+ self,
332
+ method: str,
333
+ endpoint: str,
334
+ body: Optional[str] = None,
335
+ additional_headers: Optional[Dict[str, str]] = None,
336
+ max_retries: int = 3,
337
+ ) -> Tuple[Optional[int], Optional[str]]:
338
+ """
339
+ Send an authenticated HTTP request with automatic timeout retry.
340
+
341
+ This is a convenience method that handles common request patterns
342
+ with authentication, standard headers, and automatic retry for timeout errors.
343
+
344
+ :param method: HTTP method
345
+ :param endpoint: API endpoint path
346
+ :param body: Optional request body
347
+ :param additional_headers: Optional additional headers to include
348
+ :param max_retries: Maximum number of retry attempts (default: 3)
349
+ :return: Tuple of (status_code, response_body) or (None, None) on failure
350
+ :raises QueryTimeoutError: When all retry attempts fail with timeout errors
351
+ """
352
+ return self.send_authenticated_request_with_retry(
353
+ method, endpoint, body, additional_headers, max_retries
354
+ )
355
+
213
356
  def send_token_request(
214
357
  self,
215
358
  payload: Dict[str, str],
@@ -0,0 +1,147 @@
1
+ """
2
+ Timeout detection module for the SFQ library.
3
+
4
+ This module provides utilities for detecting different types of timeout errors
5
+ that can occur during HTTP requests to Salesforce APIs.
6
+ """
7
+
8
+ from typing import Optional
9
+ import errno
10
+
11
+
12
+ class TimeoutDetector:
13
+ """Utility class for detecting timeout conditions in HTTP responses."""
14
+
15
+ # Server timeout message pattern
16
+ SERVER_TIMEOUT_MESSAGE = "Your query request was running for too long."
17
+ # Server timeout error code pattern
18
+ SERVER_TIMEOUT_ERROR_CODE = "QUERY_TIMEOUT"
19
+
20
+ @staticmethod
21
+ def is_server_timeout(status_code: Optional[int], response_body: Optional[str]) -> bool:
22
+ """
23
+ Detect server-side query timeout from HTTP response.
24
+
25
+ Server timeouts are indicated by:
26
+ - HTTP status code 400
27
+ - Response body containing the specific timeout message OR timeout error code
28
+
29
+ Args:
30
+ status_code: HTTP status code from the response
31
+ response_body: Response body content as string
32
+
33
+ Returns:
34
+ bool: True if this is a server timeout, False otherwise
35
+ """
36
+ if status_code != 400:
37
+ return False
38
+
39
+ if response_body is None or response_body == "":
40
+ return False
41
+
42
+ # Check for either the timeout message or the error code
43
+ # Handle potential encoding issues by using string containment check
44
+ try:
45
+ return (TimeoutDetector.SERVER_TIMEOUT_MESSAGE in response_body or
46
+ TimeoutDetector.SERVER_TIMEOUT_ERROR_CODE in response_body)
47
+ except (TypeError, UnicodeError):
48
+ # If there are encoding issues, assume it's not a timeout
49
+ return False
50
+
51
+ @staticmethod
52
+ def is_connection_timeout(
53
+ status_code: Optional[int],
54
+ response_body: Optional[str],
55
+ exception: Optional[Exception] = None
56
+ ) -> bool:
57
+ """
58
+ Detect connection timeout from HTTP response and exception context.
59
+
60
+ Connection timeouts are indicated by:
61
+ - HTTP status code is None
62
+ - Response body is None
63
+ - Exception with errno 110 (Connection timed out)
64
+
65
+ Args:
66
+ status_code: HTTP status code from the response (should be None)
67
+ response_body: Response body content (should be None)
68
+ exception: Exception that occurred during the request
69
+
70
+ Returns:
71
+ bool: True if this is a connection timeout, False otherwise
72
+ """
73
+ # Check for the basic connection timeout pattern
74
+ if status_code is not None or response_body is not None:
75
+ return False
76
+
77
+ # Check if we have an exception with errno 110
78
+ if exception is None:
79
+ return False
80
+
81
+ # Check for errno 110 in various exception types
82
+ if hasattr(exception, 'errno') and exception.errno == errno.ETIMEDOUT:
83
+ return True
84
+
85
+ # Check for errno in nested exceptions (common in urllib/http.client)
86
+ if hasattr(exception, '__cause__') and exception.__cause__ is not None:
87
+ cause = exception.__cause__
88
+ if hasattr(cause, 'errno') and cause.errno == errno.ETIMEDOUT:
89
+ return True
90
+
91
+ # Check for errno in args (some exceptions store it there)
92
+ if hasattr(exception, 'args') and exception.args:
93
+ for arg in exception.args:
94
+ if hasattr(arg, 'errno') and arg.errno == errno.ETIMEDOUT:
95
+ return True
96
+
97
+ return False
98
+
99
+ @staticmethod
100
+ def is_timeout_error(
101
+ status_code: Optional[int],
102
+ response_body: Optional[str],
103
+ exception: Optional[Exception] = None
104
+ ) -> bool:
105
+ """
106
+ Unified timeout detection for both server and connection timeout scenarios.
107
+
108
+ This method combines both server timeout and connection timeout detection
109
+ to provide a single interface for timeout checking.
110
+
111
+ Args:
112
+ status_code: HTTP status code from the response
113
+ response_body: Response body content as string
114
+ exception: Exception that occurred during the request
115
+
116
+ Returns:
117
+ bool: True if this is any type of timeout error, False otherwise
118
+ """
119
+ return (
120
+ TimeoutDetector.is_server_timeout(status_code, response_body) or
121
+ TimeoutDetector.is_connection_timeout(status_code, response_body, exception)
122
+ )
123
+
124
+ @staticmethod
125
+ def get_timeout_type(
126
+ status_code: Optional[int],
127
+ response_body: Optional[str],
128
+ exception: Optional[Exception] = None
129
+ ) -> Optional[str]:
130
+ """
131
+ Determine the type of timeout error.
132
+
133
+ Args:
134
+ status_code: HTTP status code from the response
135
+ response_body: Response body content as string
136
+ exception: Exception that occurred during the request
137
+
138
+ Returns:
139
+ str: 'server' for server timeouts, 'connection' for connection timeouts,
140
+ None if not a timeout error
141
+ """
142
+ if TimeoutDetector.is_server_timeout(status_code, response_body):
143
+ return 'server'
144
+ elif TimeoutDetector.is_connection_timeout(status_code, response_body, exception):
145
+ return 'connection'
146
+ else:
147
+ return None
sfq/utils.py CHANGED
@@ -197,9 +197,7 @@ def extract_org_and_user_ids(token_id_url: str) -> Tuple[str, str]:
197
197
  raise ValueError(f"Invalid token ID URL format: {token_id_url}")
198
198
 
199
199
 
200
- def dicts_to_html_table(
201
- items: List[Dict[str, Any]], styled: bool = False
202
- ) -> str:
200
+ def dicts_to_html_table(items: List[Dict[str, Any]], styled: bool = False) -> str:
203
201
  """
204
202
  Convert a list of dictionaries to a compact HTML table.
205
203
 
@@ -309,13 +307,70 @@ def dicts_to_html_table(
309
307
 
310
308
  return "".join(html)
311
309
 
312
- def records_to_html_table(records: List[Dict[str, Any]], styled: bool = False) -> str:
313
- """Convert a list of records to an HTML table."""
314
- # really we don't want anything associated with "attributes"
315
- normalized_records = []
316
- for record in records:
310
+
311
+ def flatten_dict(d, parent_key="", sep="."):
312
+ """Recursively flatten a dictionary with dot notation."""
313
+ items = {}
314
+ for k, v in d.items():
315
+ new_key = f"{parent_key}{sep}{k}" if parent_key else k
316
+ if isinstance(v, dict):
317
+ items.update(flatten_dict(v, new_key, sep=sep))
318
+ else:
319
+ items[new_key] = v
320
+ return items
321
+
322
+
323
+ def remove_attributes(obj):
324
+ """Recursively remove 'attributes' key from dicts/lists."""
325
+ if isinstance(obj, dict):
326
+ return {k: remove_attributes(v) for k, v in obj.items() if k != "attributes"}
327
+ elif isinstance(obj, list):
328
+ return [remove_attributes(item) for item in obj]
329
+ else:
330
+ return obj
331
+
332
+
333
+ def records_to_html_table(
334
+ records: List[Dict[str, Any]], headers: Dict[str, str] = None, styled: bool = False
335
+ ) -> str:
336
+ if not isinstance(records, list):
337
+ raise ValueError("records must be a list of dictionaries")
338
+
339
+ cleaned = remove_attributes(records)
340
+
341
+ flat_rows = []
342
+ for record in cleaned:
317
343
  if not isinstance(record, dict):
318
344
  raise ValueError(f"Record is not a dictionary: {record!r}")
319
- record.pop("attributes", None)
320
- normalized_records.append(record)
321
- return dicts_to_html_table(normalized_records, styled=styled)
345
+ flat_rows.append(flatten_dict(record))
346
+
347
+ # Preserve column order across all rows
348
+ seen = set()
349
+ ordered_columns = []
350
+ for row in flat_rows:
351
+ for key in row.keys():
352
+ if key not in seen:
353
+ ordered_columns.append(key)
354
+ seen.add(key)
355
+
356
+ # headers optionally remaps flattened field names to user-friendly display names
357
+ if headers is None:
358
+ headers = {}
359
+ for col in ordered_columns:
360
+ headers[col] = col
361
+ else:
362
+ for col in ordered_columns:
363
+ headers[col] = headers.get(col, col)
364
+
365
+ # Normalize rows so all have the same keys, using remapped column names
366
+ normalized_data = []
367
+ for row in flat_rows:
368
+ normalized_row = {
369
+ headers.get(col, col): (
370
+ "" if row.get(col, None) is None else row.get(col, "")
371
+ )
372
+ for col in ordered_columns
373
+ }
374
+ normalized_data.append(normalized_row)
375
+
376
+ return dicts_to_html_table(normalized_data, styled=styled)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.37
3
+ Version: 0.0.39
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
@@ -0,0 +1,15 @@
1
+ sfq/__init__.py,sha256=0jbPJh9jK3HLCyDwrjN_vJw7QcpS-CEPI7V6u9DGRfg,20570
2
+ sfq/_cometd.py,sha256=QqdSGsms9uFm7vgmxgau7m2UuLHztK1yjN-BNjeo8xM,10381
3
+ sfq/auth.py,sha256=bD7kEI5UpUAh0xpE2GzB7EatfLE0q-rqG7tOpqn_cQY,13985
4
+ sfq/crud.py,sha256=fj4wPMt0DcrMKbMWQ9AUMsUNUWicsY93LP_3Q7lhmDU,20300
5
+ sfq/debug_cleanup.py,sha256=e2_Hpigy3F7XsATOUXo8DZNmuEIL9SDD0tBlZIZeQLc,2638
6
+ sfq/exceptions.py,sha256=Ys41dV8nAeP8Cl9xr4QuL7itQxjxh5KDCse6hJNl6AY,1028
7
+ sfq/http_client.py,sha256=rs9n-wIzV2HUXKPS_1l3tQGU8afakZXwCdYgAgVNOA4,18642
8
+ sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ sfq/query.py,sha256=AoagL8PMKUcpbPPTcHJPKhmUdDDPa0La4JLC0TUN_Yc,14586
10
+ sfq/soap.py,sha256=FM4msP9ErrgLFaNOQy_kYVde8QFkT4yQu9TfMiZG0VA,7006
11
+ sfq/timeout_detector.py,sha256=Y8zO0h2dxMchNxhh5ns3GBfRv07EzKzUcr-hqZc_2-s,5503
12
+ sfq/utils.py,sha256=d1vhpTa5innn5w23dnb_9el_giOXRCuRIaP6XUnaLfc,12387
13
+ sfq-0.0.39.dist-info/METADATA,sha256=spplyZGrNFlgy-oAB6lydfUwFdPduunLj68_dvfrC4o,6899
14
+ sfq-0.0.39.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ sfq-0.0.39.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- sfq/__init__.py,sha256=Ra4GajxYD_6D17qjifkE90-udmurVkchOOUpszNpmOw,20465
2
- sfq/_cometd.py,sha256=QqdSGsms9uFm7vgmxgau7m2UuLHztK1yjN-BNjeo8xM,10381
3
- sfq/auth.py,sha256=bD7kEI5UpUAh0xpE2GzB7EatfLE0q-rqG7tOpqn_cQY,13985
4
- sfq/crud.py,sha256=fj4wPMt0DcrMKbMWQ9AUMsUNUWicsY93LP_3Q7lhmDU,20300
5
- sfq/debug_cleanup.py,sha256=e2_Hpigy3F7XsATOUXo8DZNmuEIL9SDD0tBlZIZeQLc,2638
6
- sfq/exceptions.py,sha256=HZctvGj1SGguca0oG6fqSmf3KDbq4v68FfQfqB-crpo,906
7
- sfq/http_client.py,sha256=DysAr4cn_0Aj28x_dBsR9pt_cqG_8KC8X7fEDtuIopY,11636
8
- sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- sfq/query.py,sha256=AoagL8PMKUcpbPPTcHJPKhmUdDDPa0La4JLC0TUN_Yc,14586
10
- sfq/soap.py,sha256=FM4msP9ErrgLFaNOQy_kYVde8QFkT4yQu9TfMiZG0VA,7006
11
- sfq/utils.py,sha256=AkNSnlUsFaDua9Uif1qCJvNlaxo4E0s5HYF-W8_RKKo,10758
12
- sfq-0.0.37.dist-info/METADATA,sha256=nGPDbcSM1PSq_aBfSxbtZu09kquoyJpT1gjl24RTQc4,6899
13
- sfq-0.0.37.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
- sfq-0.0.37.dist-info/RECORD,,
File without changes