aio-sf 0.1.0b8__tar.gz → 0.1.0b9__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 (47) hide show
  1. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/PKG-INFO +26 -10
  2. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/README.md +25 -9
  3. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/collections/batch.py +72 -33
  4. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/tests/test_retry_and_batch.py +25 -12
  5. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/.cursor/rules/api-structure.mdc +0 -0
  6. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/.cursor/rules/async-patterns.mdc +0 -0
  7. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/.cursor/rules/project-tooling.mdc +0 -0
  8. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/.github/workflows/publish.yml +0 -0
  9. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/.github/workflows/test.yml +0 -0
  10. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/.gitignore +0 -0
  11. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/LICENSE +0 -0
  12. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/RELEASE.md +0 -0
  13. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/pyproject.toml +0 -0
  14. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/pytest.ini +0 -0
  15. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/__init__.py +0 -0
  16. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/__init__.py +0 -0
  17. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/auth/__init__.py +0 -0
  18. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/auth/base.py +0 -0
  19. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/auth/client_credentials.py +0 -0
  20. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/auth/refresh_token.py +0 -0
  21. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/auth/sfdx_cli.py +0 -0
  22. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/auth/static_token.py +0 -0
  23. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/bulk_v2/__init__.py +0 -0
  24. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/bulk_v2/client.py +0 -0
  25. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/bulk_v2/types.py +0 -0
  26. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/client.py +0 -0
  27. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/collections/__init__.py +0 -0
  28. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/collections/client.py +0 -0
  29. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/collections/records.py +0 -0
  30. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/collections/retry.py +0 -0
  31. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/collections/types.py +0 -0
  32. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/describe/__init__.py +0 -0
  33. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/describe/client.py +0 -0
  34. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/describe/types.py +0 -0
  35. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/query/__init__.py +0 -0
  36. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/query/client.py +0 -0
  37. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/query/types.py +0 -0
  38. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/api/types.py +0 -0
  39. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/exporter/__init__.py +0 -0
  40. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/exporter/bulk_export.py +0 -0
  41. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/src/aio_sf/exporter/parquet_writer.py +0 -0
  42. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/tests/__init__.py +0 -0
  43. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/tests/conftest.py +0 -0
  44. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/tests/test_api_clients.py +0 -0
  45. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/tests/test_auth.py +0 -0
  46. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/tests/test_client.py +0 -0
  47. {aio_sf-0.1.0b8 → aio_sf-0.1.0b9}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aio-sf
3
- Version: 0.1.0b8
3
+ Version: 0.1.0b9
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
@@ -153,24 +153,40 @@ asyncio.run(main())
153
153
 
154
154
  ### Collections API - Batch Operations
155
155
 
156
- Efficiently handle bulk operations with automatic batching and concurrency:
156
+ Bulk operations (insert, update, upsert, delete) with automatic batching and concurrency.
157
157
 
158
+ **Basic Usage:**
158
159
  ```python
159
160
  async with SalesforceClient(auth_strategy=auth) as sf:
160
161
  records = [{"Name": f"Account {i}"} for i in range(1000)]
161
162
 
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
-
163
+ results = await sf.collections.insert(records, sobject_type="Account")
168
164
  # Also: update(), upsert(), delete()
169
165
  ```
170
166
 
171
- **Features:** Automatic batching Concurrent processing Order preservation • Smart retries • Progress tracking
167
+ **Advanced - With Retries, Concurrency Scaling, and Progress:**
168
+ ```python
169
+ from aio_sf.api.collections import ProgressInfo
170
+
171
+ async def on_progress(info: ProgressInfo):
172
+ print(
173
+ f"Attempt {info['current_attempt']}: "
174
+ f"{info['records_succeeded']} succeeded, "
175
+ f"{info['records_failed']} failed, "
176
+ f"{info['records_pending']} pending"
177
+ )
178
+
179
+ async with SalesforceClient(auth_strategy=auth) as sf:
180
+ results = await sf.collections.insert(
181
+ records=records,
182
+ sobject_type="Account",
183
+ batch_size=[200, 100, 25], # Shrink batch size on retry
184
+ max_concurrent_batches=[5, 3, 1], # Reduce concurrency on retry
185
+ max_attempts=5, # Retry up to 5 times
186
+ on_batch_complete=on_progress, # Progress callback
187
+ )
188
+ ```
172
189
 
173
- See [RETRY_GUIDE.md](RETRY_GUIDE.md) for advanced retry strategies, progress tracking, and custom error handling.
174
190
 
175
191
  ## Exporter
176
192
 
@@ -90,24 +90,40 @@ asyncio.run(main())
90
90
 
91
91
  ### Collections API - Batch Operations
92
92
 
93
- Efficiently handle bulk operations with automatic batching and concurrency:
93
+ Bulk operations (insert, update, upsert, delete) with automatic batching and concurrency.
94
94
 
95
+ **Basic Usage:**
95
96
  ```python
96
97
  async with SalesforceClient(auth_strategy=auth) as sf:
97
98
  records = [{"Name": f"Account {i}"} for i in range(1000)]
98
99
 
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
-
100
+ results = await sf.collections.insert(records, sobject_type="Account")
105
101
  # Also: update(), upsert(), delete()
106
102
  ```
107
103
 
108
- **Features:** Automatic batching Concurrent processing Order preservation • Smart retries • Progress tracking
104
+ **Advanced - With Retries, Concurrency Scaling, and Progress:**
105
+ ```python
106
+ from aio_sf.api.collections import ProgressInfo
107
+
108
+ async def on_progress(info: ProgressInfo):
109
+ print(
110
+ f"Attempt {info['current_attempt']}: "
111
+ f"{info['records_succeeded']} succeeded, "
112
+ f"{info['records_failed']} failed, "
113
+ f"{info['records_pending']} pending"
114
+ )
115
+
116
+ async with SalesforceClient(auth_strategy=auth) as sf:
117
+ results = await sf.collections.insert(
118
+ records=records,
119
+ sobject_type="Account",
120
+ batch_size=[200, 100, 25], # Shrink batch size on retry
121
+ max_concurrent_batches=[5, 3, 1], # Reduce concurrency on retry
122
+ max_attempts=5, # Retry up to 5 times
123
+ on_batch_complete=on_progress, # Progress callback
124
+ )
125
+ ```
109
126
 
110
- See [RETRY_GUIDE.md](RETRY_GUIDE.md) for advanced retry strategies, progress tracking, and custom error handling.
111
127
 
112
128
  ## Exporter
113
129
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union
5
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, TypedDict, Union
6
6
 
7
7
  from .retry import (
8
8
  RecordWithAttempt,
@@ -20,12 +20,14 @@ logger = logging.getLogger(__name__)
20
20
  class ProgressInfo(TypedDict):
21
21
  """Progress information for batch operations."""
22
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
23
+ total_records: int # Total records being processed
24
+ records_completed: int # Records finished (succeeded or failed permanently)
25
+ records_succeeded: int # Records that succeeded
26
+ records_failed: int # Records that failed permanently (exhausted retries)
27
+ records_pending: int # Records still being retried
28
+ current_attempt: int # Current retry attempt number (1-indexed)
29
+ current_batch_size: int # Batch size for current attempt
30
+ current_concurrency: int # Concurrency level for current attempt
29
31
 
30
32
 
31
33
  # Type alias for progress callback
@@ -64,6 +66,7 @@ async def process_batches_concurrently(
64
66
  max_concurrent_batches: int,
65
67
  total_records: int,
66
68
  on_batch_complete: Optional[ProgressCallback] = None,
69
+ progress_state: Optional[Dict[str, int]] = None,
67
70
  *args,
68
71
  **kwargs,
69
72
  ) -> List[Any]:
@@ -78,6 +81,7 @@ async def process_batches_concurrently(
78
81
  :param max_concurrent_batches: Maximum number of concurrent batch operations
79
82
  :param total_records: Total number of records being processed
80
83
  :param on_batch_complete: Optional callback invoked after each batch completes
84
+ :param progress_state: Dict with progress state (updated by caller)
81
85
  :param args: Additional positional arguments for operation_func
82
86
  :param kwargs: Additional keyword arguments for operation_func
83
87
  :returns: List of results from all batches in the same order as input
@@ -87,12 +91,9 @@ async def process_batches_concurrently(
87
91
  raise ValueError("max_concurrent_batches must be greater than 0")
88
92
 
89
93
  semaphore = asyncio.Semaphore(max_concurrent_batches)
90
- total_batches = len(batches)
91
94
  callback_lock = asyncio.Lock() if on_batch_complete else None
92
- records_processed = 0
93
95
 
94
96
  async def process_batch_with_semaphore(batch_index: int, batch):
95
- nonlocal records_processed
96
97
  async with semaphore:
97
98
  try:
98
99
  result = await operation_func(batch, *args, **kwargs)
@@ -104,17 +105,17 @@ async def process_batches_concurrently(
104
105
  result = [e for _ in range(len(batch))]
105
106
 
106
107
  # Invoke progress callback if provided
107
- if on_batch_complete and callback_lock:
108
- batch_size = len(batch)
108
+ if on_batch_complete and callback_lock and progress_state:
109
109
  async with callback_lock:
110
- records_processed += batch_size
111
110
  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
111
+ "total_records": progress_state["total_records"],
112
+ "records_completed": progress_state["records_completed"],
113
+ "records_succeeded": progress_state["records_succeeded"],
114
+ "records_failed": progress_state["records_failed"],
115
+ "records_pending": progress_state["records_pending"],
116
+ "current_attempt": progress_state["current_attempt"],
117
+ "current_batch_size": progress_state["current_batch_size"],
118
+ "current_concurrency": progress_state["current_concurrency"],
118
119
  }
119
120
  await on_batch_complete(progress_info)
120
121
 
@@ -163,9 +164,21 @@ async def process_with_retries(
163
164
  # Initialize result array with None placeholders
164
165
  max_index = max(r.original_index for r in records_with_attempts)
165
166
  final_results: List[Optional[CollectionResult]] = [None] * (max_index + 1)
167
+ total_records_count = max_index + 1
168
+
169
+ # Initialize progress state
170
+ progress_state = {
171
+ "total_records": total_records_count,
172
+ "records_completed": 0,
173
+ "records_succeeded": 0,
174
+ "records_failed": 0,
175
+ "records_pending": total_records_count,
176
+ "current_attempt": 1,
177
+ "current_batch_size": 0,
178
+ "current_concurrency": 0,
179
+ }
166
180
 
167
181
  current_records = records_with_attempts
168
- total_retry_count = 0
169
182
 
170
183
  while current_records:
171
184
  current_attempt = current_records[0].attempt
@@ -176,6 +189,11 @@ async def process_with_retries(
176
189
  current_attempt, max_concurrent_batches
177
190
  )
178
191
 
192
+ # Update progress state for current attempt
193
+ progress_state["current_attempt"] = current_attempt
194
+ progress_state["current_batch_size"] = current_batch_size
195
+ progress_state["current_concurrency"] = current_concurrency
196
+
179
197
  logger.debug(
180
198
  f"Processing {len(current_records)} records on attempt {current_attempt} "
181
199
  f"with batch_size={current_batch_size}, concurrency={current_concurrency}"
@@ -185,23 +203,14 @@ async def process_with_retries(
185
203
  records_to_process = [r.record for r in current_records]
186
204
  batches = split_into_batches(records_to_process, current_batch_size, max_limit)
187
205
 
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
206
+ # Process batches with current concurrency level (no callback here)
199
207
  batch_results = await process_batches_concurrently(
200
208
  batches,
201
209
  operation_func,
202
210
  current_concurrency,
203
211
  len(records_to_process),
204
- wrapped_callback,
212
+ None, # Don't invoke callback during batch processing
213
+ None,
205
214
  *args,
206
215
  **kwargs,
207
216
  )
@@ -215,12 +224,42 @@ async def process_with_retries(
215
224
  final_results,
216
225
  )
217
226
 
227
+ # Update progress state based on results
228
+ # Count completed records (those not being retried)
229
+ records_completed_this_round = len(current_records) - len(records_to_retry)
230
+
231
+ # Count successes and failures in final_results so far
232
+ records_succeeded = sum(
233
+ 1 for r in final_results if r is not None and r.get("success", False)
234
+ )
235
+ records_failed = sum(
236
+ 1 for r in final_results if r is not None and not r.get("success", False)
237
+ )
238
+
239
+ progress_state["records_completed"] = records_succeeded + records_failed
240
+ progress_state["records_succeeded"] = records_succeeded
241
+ progress_state["records_failed"] = records_failed
242
+ progress_state["records_pending"] = len(records_to_retry)
243
+
244
+ # Invoke progress callback after we know the results
245
+ if on_batch_complete:
246
+ progress_info: ProgressInfo = {
247
+ "total_records": progress_state["total_records"],
248
+ "records_completed": progress_state["records_completed"],
249
+ "records_succeeded": progress_state["records_succeeded"],
250
+ "records_failed": progress_state["records_failed"],
251
+ "records_pending": progress_state["records_pending"],
252
+ "current_attempt": progress_state["current_attempt"],
253
+ "current_batch_size": progress_state["current_batch_size"],
254
+ "current_concurrency": progress_state["current_concurrency"],
255
+ }
256
+ await on_batch_complete(progress_info)
257
+
218
258
  if records_to_retry:
219
259
  logger.info(
220
260
  f"Retrying {len(records_to_retry)} failed records "
221
261
  f"(attempt {records_to_retry[0].attempt})"
222
262
  )
223
- total_retry_count += len(records_to_retry)
224
263
 
225
264
  current_records = records_to_retry
226
265
 
@@ -476,13 +476,18 @@ class TestProgressTracking:
476
476
  )
477
477
 
478
478
  assert len(results) == 300
479
- # Should have 2 progress callbacks (one per batch)
480
- assert len(progress_calls) == 2
479
+ # With the new design, callback is invoked once per attempt (after all batches)
480
+ # Since all records succeed on first attempt, we get 1 callback
481
+ assert len(progress_calls) == 1
481
482
 
482
- # Verify progress data
483
- assert progress_calls[0]["total_batches"] == 2
483
+ # Verify progress data - after attempt completes, all succeeded
484
484
  assert progress_calls[0]["total_records"] == 300
485
- assert progress_calls[0]["retry_count"] == 0
485
+ assert progress_calls[0]["current_batch_size"] == 200
486
+ assert progress_calls[0]["current_concurrency"] == 5 # default
487
+ assert progress_calls[0]["current_attempt"] == 1
488
+ assert progress_calls[0]["records_succeeded"] == 300
489
+ assert progress_calls[0]["records_failed"] == 0
490
+ assert progress_calls[0]["records_pending"] == 0
486
491
 
487
492
  @pytest.mark.asyncio
488
493
  async def test_progress_with_retries(self, client):
@@ -530,10 +535,18 @@ class TestProgressTracking:
530
535
 
531
536
  # Should have 2 progress callbacks (initial + retry)
532
537
  assert len(progress_calls) == 2
533
- # First has no retries
534
- assert progress_calls[0]["retry_count"] == 0
535
- # Second shows retries
536
- assert progress_calls[1]["retry_count"] == 10
538
+
539
+ # First attempt: all failed, all pending retry
540
+ assert progress_calls[0]["current_attempt"] == 1
541
+ assert progress_calls[0]["records_succeeded"] == 0
542
+ assert progress_calls[0]["records_failed"] == 0
543
+ assert progress_calls[0]["records_pending"] == 10
544
+
545
+ # Second attempt: all succeeded
546
+ assert progress_calls[1]["current_attempt"] == 2
547
+ assert progress_calls[1]["records_succeeded"] == 10
548
+ assert progress_calls[1]["records_failed"] == 0
549
+ assert progress_calls[1]["records_pending"] == 0
537
550
 
538
551
 
539
552
  class TestConcurrencyScaling:
@@ -646,7 +659,7 @@ class TestHTTPErrorHandling:
646
659
  async def test_default_retries_transient_http_errors(self, client):
647
660
  """Test that default behavior retries transient HTTP errors."""
648
661
  import httpx
649
-
662
+
650
663
  collections_api = CollectionsAPI(client)
651
664
 
652
665
  records = [{"Name": "Account 1"}]
@@ -681,7 +694,7 @@ class TestHTTPErrorHandling:
681
694
  async def test_default_does_not_retry_4xx_errors(self, client):
682
695
  """Test that default behavior does NOT retry 4xx client errors."""
683
696
  import httpx
684
-
697
+
685
698
  collections_api = CollectionsAPI(client)
686
699
 
687
700
  records = [{"Name": "Account 1"}]
@@ -716,7 +729,7 @@ class TestHTTPErrorHandling:
716
729
  async def test_http_error_converts_to_retryable_failure(self, client):
717
730
  """Test that transient HTTP errors are retried and converted to results."""
718
731
  import httpx
719
-
732
+
720
733
  collections_api = CollectionsAPI(client)
721
734
 
722
735
  records = [{"Name": "Account 1"}, {"Name": "Account 2"}]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes