aio-sf 0.1.0b7__tar.gz → 0.1.0b8__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.
Files changed (49) hide show
  1. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/PKG-INFO +20 -1
  2. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/README.md +19 -0
  3. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/__init__.py +0 -12
  4. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/collections/__init__.py +6 -12
  5. aio_sf-0.1.0b8/src/aio_sf/api/collections/batch.py +328 -0
  6. aio_sf-0.1.0b8/src/aio_sf/api/collections/client.py +463 -0
  7. aio_sf-0.1.0b8/src/aio_sf/api/collections/records.py +138 -0
  8. aio_sf-0.1.0b8/src/aio_sf/api/collections/retry.py +141 -0
  9. aio_sf-0.1.0b8/src/aio_sf/api/collections/types.py +28 -0
  10. aio_sf-0.1.0b8/tests/test_retry_and_batch.py +877 -0
  11. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/uv.lock +14 -8
  12. aio_sf-0.1.0b7/src/aio_sf/api/collections/client.py +0 -660
  13. aio_sf-0.1.0b7/src/aio_sf/api/collections/types.py +0 -70
  14. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/.cursor/rules/api-structure.mdc +0 -0
  15. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/.cursor/rules/async-patterns.mdc +0 -0
  16. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/.cursor/rules/project-tooling.mdc +0 -0
  17. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/.github/workflows/publish.yml +0 -0
  18. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/.github/workflows/test.yml +0 -0
  19. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/.gitignore +0 -0
  20. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/LICENSE +0 -0
  21. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/RELEASE.md +0 -0
  22. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/pyproject.toml +0 -0
  23. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/pytest.ini +0 -0
  24. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/__init__.py +0 -0
  25. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/auth/__init__.py +0 -0
  26. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/auth/base.py +0 -0
  27. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/auth/client_credentials.py +0 -0
  28. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/auth/refresh_token.py +0 -0
  29. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/auth/sfdx_cli.py +0 -0
  30. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/auth/static_token.py +0 -0
  31. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/bulk_v2/__init__.py +0 -0
  32. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/bulk_v2/client.py +0 -0
  33. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/bulk_v2/types.py +0 -0
  34. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/client.py +0 -0
  35. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/describe/__init__.py +0 -0
  36. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/describe/client.py +0 -0
  37. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/describe/types.py +0 -0
  38. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/query/__init__.py +0 -0
  39. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/query/client.py +0 -0
  40. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/query/types.py +0 -0
  41. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/api/types.py +0 -0
  42. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/exporter/__init__.py +0 -0
  43. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/exporter/bulk_export.py +0 -0
  44. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/src/aio_sf/exporter/parquet_writer.py +0 -0
  45. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/tests/__init__.py +0 -0
  46. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/tests/conftest.py +0 -0
  47. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/tests/test_api_clients.py +0 -0
  48. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/tests/test_auth.py +0 -0
  49. {aio_sf-0.1.0b7 → aio_sf-0.1.0b8}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aio-sf
3
- Version: 0.1.0b7
3
+ Version: 0.1.0b8
4
4
  Summary: Async Salesforce library for Python
5
5
  Project-URL: Homepage, https://github.com/callawaycloud/aio-salesforce
6
6
  Project-URL: Repository, https://github.com/callawaycloud/aio-salesforce
@@ -151,7 +151,26 @@ async def main():
151
151
  asyncio.run(main())
152
152
  ```
153
153
 
154
+ ### Collections API - Batch Operations
154
155
 
156
+ Efficiently handle bulk operations with automatic batching and concurrency:
157
+
158
+ ```python
159
+ async with SalesforceClient(auth_strategy=auth) as sf:
160
+ records = [{"Name": f"Account {i}"} for i in range(1000)]
161
+
162
+ # Insert with automatic batching and parallel processing
163
+ results = await sf.collections.insert(
164
+ records, sobject_type="Account",
165
+ batch_size=200, max_concurrent_batches=5
166
+ )
167
+
168
+ # Also: update(), upsert(), delete()
169
+ ```
170
+
171
+ **Features:** Automatic batching • Concurrent processing • Order preservation • Smart retries • Progress tracking
172
+
173
+ See [RETRY_GUIDE.md](RETRY_GUIDE.md) for advanced retry strategies, progress tracking, and custom error handling.
155
174
 
156
175
  ## Exporter
157
176
 
@@ -88,7 +88,26 @@ async def main():
88
88
  asyncio.run(main())
89
89
  ```
90
90
 
91
+ ### Collections API - Batch Operations
91
92
 
93
+ Efficiently handle bulk operations with automatic batching and concurrency:
94
+
95
+ ```python
96
+ async with SalesforceClient(auth_strategy=auth) as sf:
97
+ records = [{"Name": f"Account {i}"} for i in range(1000)]
98
+
99
+ # Insert with automatic batching and parallel processing
100
+ results = await sf.collections.insert(
101
+ records, sobject_type="Account",
102
+ batch_size=200, max_concurrent_batches=5
103
+ )
104
+
105
+ # Also: update(), upsert(), delete()
106
+ ```
107
+
108
+ **Features:** Automatic batching • Concurrent processing • Order preservation • Smart retries • Progress tracking
109
+
110
+ See [RETRY_GUIDE.md](RETRY_GUIDE.md) for advanced retry strategies, progress tracking, and custom error handling.
92
111
 
93
112
  ## Exporter
94
113
 
@@ -19,13 +19,7 @@ from .bulk_v2 import (
19
19
  from .collections import (
20
20
  CollectionsAPI,
21
21
  CollectionError,
22
- CollectionRequest,
23
22
  CollectionResult,
24
- CollectionResponse,
25
- InsertCollectionRequest,
26
- UpdateCollectionRequest,
27
- UpsertCollectionRequest,
28
- DeleteCollectionRequest,
29
23
  CollectionInsertResponse,
30
24
  CollectionUpdateResponse,
31
25
  CollectionUpsertResponse,
@@ -69,13 +63,7 @@ __all__ = [
69
63
  "BulkJobError",
70
64
  # Collections Types
71
65
  "CollectionError",
72
- "CollectionRequest",
73
66
  "CollectionResult",
74
- "CollectionResponse",
75
- "InsertCollectionRequest",
76
- "UpdateCollectionRequest",
77
- "UpsertCollectionRequest",
78
- "DeleteCollectionRequest",
79
67
  "CollectionInsertResponse",
80
68
  "CollectionUpdateResponse",
81
69
  "CollectionUpsertResponse",
@@ -1,15 +1,11 @@
1
1
  """Salesforce Collections API module."""
2
2
 
3
3
  from .client import CollectionsAPI
4
+ from .batch import ProgressInfo, ProgressCallback
5
+ from .retry import ShouldRetryCallback, default_should_retry
4
6
  from .types import (
5
7
  CollectionError,
6
- CollectionRequest,
7
8
  CollectionResult,
8
- CollectionResponse,
9
- InsertCollectionRequest,
10
- UpdateCollectionRequest,
11
- UpsertCollectionRequest,
12
- DeleteCollectionRequest,
13
9
  CollectionInsertResponse,
14
10
  CollectionUpdateResponse,
15
11
  CollectionUpsertResponse,
@@ -18,14 +14,12 @@ from .types import (
18
14
 
19
15
  __all__ = [
20
16
  "CollectionsAPI",
17
+ "ProgressInfo",
18
+ "ProgressCallback",
19
+ "ShouldRetryCallback",
20
+ "default_should_retry",
21
21
  "CollectionError",
22
- "CollectionRequest",
23
22
  "CollectionResult",
24
- "CollectionResponse",
25
- "InsertCollectionRequest",
26
- "UpdateCollectionRequest",
27
- "UpsertCollectionRequest",
28
- "DeleteCollectionRequest",
29
23
  "CollectionInsertResponse",
30
24
  "CollectionUpdateResponse",
31
25
  "CollectionUpsertResponse",
@@ -0,0 +1,328 @@
1
+ """Batch processing and concurrency management for Collections API."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union
6
+
7
+ from .retry import (
8
+ RecordWithAttempt,
9
+ ShouldRetryCallback,
10
+ convert_exception_to_result,
11
+ get_value_for_attempt,
12
+ should_retry_record,
13
+ )
14
+ from .types import CollectionResult
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ProgressInfo(TypedDict):
21
+ """Progress information for batch operations."""
22
+
23
+ batch_index: int # Current batch number (0-indexed)
24
+ total_batches: int # Total number of batches
25
+ records_processed: int # Number of records processed so far
26
+ total_records: int # Total number of records to process
27
+ batch_size: int # Size of the current batch
28
+ retry_count: int # Number of records being retried in this operation
29
+
30
+
31
+ # Type alias for progress callback
32
+ ProgressCallback = Callable[[ProgressInfo], Awaitable[None]]
33
+
34
+
35
+ def split_into_batches(
36
+ items: List[Any], batch_size: int, max_limit: int
37
+ ) -> List[List[Any]]:
38
+ """
39
+ Split a list of items into batches of specified size.
40
+
41
+ :param items: List of items to split
42
+ :param batch_size: Maximum size of each batch
43
+ :param max_limit: Maximum allowed batch size for the operation
44
+ :returns: List of batches
45
+ :raises ValueError: If batch_size is invalid
46
+ """
47
+ if batch_size <= 0:
48
+ raise ValueError("batch_size must be greater than 0")
49
+ if batch_size > max_limit:
50
+ raise ValueError(
51
+ f"batch_size ({batch_size}) cannot exceed Salesforce limit ({max_limit})"
52
+ )
53
+
54
+ batches = []
55
+ for i in range(0, len(items), batch_size):
56
+ batch = items[i : i + batch_size]
57
+ batches.append(batch)
58
+ return batches
59
+
60
+
61
+ async def process_batches_concurrently(
62
+ batches: List[Any],
63
+ operation_func,
64
+ max_concurrent_batches: int,
65
+ total_records: int,
66
+ on_batch_complete: Optional[ProgressCallback] = None,
67
+ *args,
68
+ **kwargs,
69
+ ) -> List[Any]:
70
+ """
71
+ Process batches concurrently with a limit on concurrent operations.
72
+
73
+ Order preservation: Results are returned in the same order as input batches,
74
+ regardless of which batch completes first.
75
+
76
+ :param batches: List of batches to process
77
+ :param operation_func: Function to call for each batch
78
+ :param max_concurrent_batches: Maximum number of concurrent batch operations
79
+ :param total_records: Total number of records being processed
80
+ :param on_batch_complete: Optional callback invoked after each batch completes
81
+ :param args: Additional positional arguments for operation_func
82
+ :param kwargs: Additional keyword arguments for operation_func
83
+ :returns: List of results from all batches in the same order as input
84
+ :raises ValueError: If max_concurrent_batches is invalid
85
+ """
86
+ if max_concurrent_batches <= 0:
87
+ raise ValueError("max_concurrent_batches must be greater than 0")
88
+
89
+ semaphore = asyncio.Semaphore(max_concurrent_batches)
90
+ total_batches = len(batches)
91
+ callback_lock = asyncio.Lock() if on_batch_complete else None
92
+ records_processed = 0
93
+
94
+ async def process_batch_with_semaphore(batch_index: int, batch):
95
+ nonlocal records_processed
96
+ async with semaphore:
97
+ try:
98
+ result = await operation_func(batch, *args, **kwargs)
99
+ except Exception as e:
100
+ # HTTP/network error - return the exception for each record
101
+ logger.warning(
102
+ f"Batch {batch_index} failed with exception: {type(e).__name__}: {e}"
103
+ )
104
+ result = [e for _ in range(len(batch))]
105
+
106
+ # Invoke progress callback if provided
107
+ if on_batch_complete and callback_lock:
108
+ batch_size = len(batch)
109
+ async with callback_lock:
110
+ records_processed += batch_size
111
+ progress_info: ProgressInfo = {
112
+ "batch_index": batch_index,
113
+ "total_batches": total_batches,
114
+ "records_processed": records_processed,
115
+ "total_records": total_records,
116
+ "batch_size": batch_size,
117
+ "retry_count": 0, # Set by wrapper in process_with_retries
118
+ }
119
+ await on_batch_complete(progress_info)
120
+
121
+ return result
122
+
123
+ # Process all batches concurrently with semaphore limiting concurrency
124
+ tasks = [process_batch_with_semaphore(i, batch) for i, batch in enumerate(batches)]
125
+ # asyncio.gather() preserves order
126
+ results = await asyncio.gather(*tasks)
127
+
128
+ # Flatten results from all batches, maintaining order
129
+ flattened_results = []
130
+ for batch_result in results:
131
+ flattened_results.extend(batch_result)
132
+
133
+ return flattened_results
134
+
135
+
136
+ async def process_with_retries(
137
+ records_with_attempts: List[RecordWithAttempt],
138
+ operation_func,
139
+ batch_size: Union[int, List[int]],
140
+ max_attempts: int,
141
+ should_retry_callback: Optional[ShouldRetryCallback],
142
+ max_concurrent_batches: Union[int, List[int]],
143
+ on_batch_complete: Optional[ProgressCallback],
144
+ max_limit: int,
145
+ *args,
146
+ **kwargs,
147
+ ) -> List[CollectionResult]:
148
+ """
149
+ Process records with retry logic, shrinking batch sizes, and scaling concurrency.
150
+
151
+ :param records_with_attempts: List of records with attempt tracking
152
+ :param operation_func: The single-batch operation function to call
153
+ :param batch_size: Batch size (int or list of ints per attempt)
154
+ :param max_attempts: Maximum number of attempts per record
155
+ :param should_retry_callback: Optional callback to determine if record should be retried
156
+ :param max_concurrent_batches: Maximum concurrent batches (int or list of ints per attempt)
157
+ :param on_batch_complete: Progress callback
158
+ :param max_limit: Maximum batch size limit for the operation
159
+ :param args: Additional args for operation_func
160
+ :param kwargs: Additional kwargs for operation_func
161
+ :returns: List of results in order of original input
162
+ """
163
+ # Initialize result array with None placeholders
164
+ max_index = max(r.original_index for r in records_with_attempts)
165
+ final_results: List[Optional[CollectionResult]] = [None] * (max_index + 1)
166
+
167
+ current_records = records_with_attempts
168
+ total_retry_count = 0
169
+
170
+ while current_records:
171
+ current_attempt = current_records[0].attempt
172
+ current_batch_size = min(
173
+ get_value_for_attempt(current_attempt, batch_size), max_limit
174
+ )
175
+ current_concurrency = get_value_for_attempt(
176
+ current_attempt, max_concurrent_batches
177
+ )
178
+
179
+ logger.debug(
180
+ f"Processing {len(current_records)} records on attempt {current_attempt} "
181
+ f"with batch_size={current_batch_size}, concurrency={current_concurrency}"
182
+ )
183
+
184
+ # Extract records and split into batches
185
+ records_to_process = [r.record for r in current_records]
186
+ batches = split_into_batches(records_to_process, current_batch_size, max_limit)
187
+
188
+ # Wrap progress callback to include retry count
189
+ wrapped_callback = None
190
+ if on_batch_complete:
191
+
192
+ async def progress_wrapper(progress: ProgressInfo):
193
+ progress["retry_count"] = total_retry_count
194
+ await on_batch_complete(progress)
195
+
196
+ wrapped_callback = progress_wrapper
197
+
198
+ # Process batches with current concurrency level
199
+ batch_results = await process_batches_concurrently(
200
+ batches,
201
+ operation_func,
202
+ current_concurrency,
203
+ len(records_to_process),
204
+ wrapped_callback,
205
+ *args,
206
+ **kwargs,
207
+ )
208
+
209
+ # Process results and determine retries
210
+ records_to_retry = await _collect_records_for_retry(
211
+ current_records,
212
+ batch_results,
213
+ max_attempts,
214
+ should_retry_callback,
215
+ final_results,
216
+ )
217
+
218
+ if records_to_retry:
219
+ logger.info(
220
+ f"Retrying {len(records_to_retry)} failed records "
221
+ f"(attempt {records_to_retry[0].attempt})"
222
+ )
223
+ total_retry_count += len(records_to_retry)
224
+
225
+ current_records = records_to_retry
226
+
227
+ # Return results (all should be non-None at this point)
228
+ return [r for r in final_results if r is not None]
229
+
230
+
231
+ async def _collect_records_for_retry(
232
+ current_records: List[RecordWithAttempt],
233
+ batch_results: List[Union[CollectionResult, Exception]],
234
+ max_attempts: int,
235
+ should_retry_callback: Optional[ShouldRetryCallback],
236
+ final_results: List[Optional[CollectionResult]],
237
+ ) -> List[RecordWithAttempt]:
238
+ """Process results and collect records that should be retried."""
239
+ records_to_retry: List[RecordWithAttempt] = []
240
+
241
+ for record_wrapper, result in zip(current_records, batch_results):
242
+ original_index = record_wrapper.original_index
243
+
244
+ if isinstance(result, Exception):
245
+ # HTTP/network error
246
+ await _handle_exception_result(
247
+ record_wrapper,
248
+ result,
249
+ max_attempts,
250
+ should_retry_callback,
251
+ records_to_retry,
252
+ final_results,
253
+ original_index,
254
+ )
255
+ elif result.get("success", False):
256
+ # Success - store the result
257
+ final_results[original_index] = result
258
+ else:
259
+ # Failed CollectionResult
260
+ await _handle_failed_result(
261
+ record_wrapper,
262
+ result,
263
+ max_attempts,
264
+ should_retry_callback,
265
+ records_to_retry,
266
+ final_results,
267
+ original_index,
268
+ )
269
+
270
+ return records_to_retry
271
+
272
+
273
+ async def _handle_exception_result(
274
+ record_wrapper: RecordWithAttempt,
275
+ exception: Exception,
276
+ max_attempts: int,
277
+ should_retry_callback: Optional[ShouldRetryCallback],
278
+ records_to_retry: List[RecordWithAttempt],
279
+ final_results: List[Optional[CollectionResult]],
280
+ original_index: int,
281
+ ) -> None:
282
+ """Handle an exception result - either retry or convert to error result."""
283
+ can_retry = record_wrapper.attempt < max_attempts
284
+ if can_retry and await should_retry_record(
285
+ record_wrapper.record,
286
+ exception,
287
+ record_wrapper.attempt,
288
+ should_retry_callback,
289
+ ):
290
+ records_to_retry.append(
291
+ RecordWithAttempt(
292
+ record_wrapper.record,
293
+ original_index,
294
+ record_wrapper.attempt + 1,
295
+ )
296
+ )
297
+ else:
298
+ # No more retries - convert Exception to CollectionResult format
299
+ final_results[original_index] = convert_exception_to_result(exception)
300
+
301
+
302
+ async def _handle_failed_result(
303
+ record_wrapper: RecordWithAttempt,
304
+ result: CollectionResult,
305
+ max_attempts: int,
306
+ should_retry_callback: Optional[ShouldRetryCallback],
307
+ records_to_retry: List[RecordWithAttempt],
308
+ final_results: List[Optional[CollectionResult]],
309
+ original_index: int,
310
+ ) -> None:
311
+ """Handle a failed CollectionResult - either retry or store the failure."""
312
+ can_retry = record_wrapper.attempt < max_attempts
313
+ if can_retry and await should_retry_record(
314
+ record_wrapper.record,
315
+ result,
316
+ record_wrapper.attempt,
317
+ should_retry_callback,
318
+ ):
319
+ records_to_retry.append(
320
+ RecordWithAttempt(
321
+ record_wrapper.record,
322
+ original_index,
323
+ record_wrapper.attempt + 1,
324
+ )
325
+ )
326
+ else:
327
+ # No more retries - store the failed result
328
+ final_results[original_index] = result