aio-sf 0.1.0b1__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.
@@ -0,0 +1,27 @@
1
+ """aio-salesforce: Async Salesforce library for Python with Bulk API 2.0 support."""
2
+
3
+ __version__ = "0.1.0b1"
4
+ __author__ = "Jonas"
5
+ __email__ = "charlie@callaway.cloud"
6
+
7
+ # Connection functionality
8
+ from .connection import ( # noqa: F401
9
+ SalesforceConnection,
10
+ SalesforceAuthError,
11
+ AuthStrategy,
12
+ ClientCredentialsAuth,
13
+ RefreshTokenAuth,
14
+ StaticTokenAuth,
15
+ )
16
+
17
+ # Core package only exports connection functionality
18
+ # Users import exporter functions directly: from aio_salesforce.exporter import bulk_query
19
+
20
+ __all__ = [
21
+ "SalesforceConnection",
22
+ "SalesforceAuthError",
23
+ "AuthStrategy",
24
+ "ClientCredentialsAuth",
25
+ "RefreshTokenAuth",
26
+ "StaticTokenAuth",
27
+ ]
@@ -0,0 +1,107 @@
1
+ # Salesforce API Organization
2
+
3
+ This directory contains organized Salesforce API clients following a consistent convention.
4
+
5
+ ## Directory Structure Convention
6
+
7
+ Each API module follows this structure:
8
+
9
+ ```
10
+ api/
11
+ ├── {api_name}/
12
+ │ ├── __init__.py # Export API client and types
13
+ │ ├── client.py # Main API client class
14
+ │ └── types.py # TypedDict definitions for responses
15
+ └── __init__.py # Export all APIs and types
16
+ ```
17
+
18
+ ## Current APIs
19
+
20
+ ### `describe/` - Describe API
21
+ - **Client**: `DescribeAPI`
22
+ - **Purpose**: Object describe/metadata, organization info, limits
23
+ - **Key Methods**:
24
+ - `describe_sobject()` → `SObjectDescribe`
25
+ - `list_sobjects()` → `List[SObjectInfo]`
26
+ - `get_organization_info()` → `OrganizationInfo`
27
+ - `get_limits()` → `OrganizationLimits`
28
+
29
+ ### `bulk_v2/` - Bulk API v2
30
+ - **Client**: `BulkV2API`
31
+ - **Purpose**: Large data operations, bulk queries
32
+ - **Key Methods**:
33
+ - `create_job()` → `BulkJobInfo`
34
+ - `get_job_status()` → `BulkJobStatus`
35
+ - `get_job_results()` → `Tuple[str, Optional[str]]`
36
+ - `wait_for_job_completion()` → `BulkJobStatus`
37
+
38
+ ### `query/` - Query API
39
+ - **Client**: `QueryAPI`
40
+ - **Purpose**: SOQL queries, QueryMore, SOSL search
41
+ - **Key Methods**:
42
+ - `soql(query, include_deleted=False)` → `QueryResult` (with async iteration)
43
+ - `sosl(search)` → `List[Dict[str, Any]]` (SOSL search)
44
+ - `explain(query)` → `Dict[str, Any]` (query execution plan)
45
+ - `query_more()` → `QueryMoreResponse` (internal pagination)
46
+ - **Features**: SOQL injection protection, automatic pagination, deleted records support
47
+ - **Note**: Batch size is controlled by Salesforce, not configurable in Query API
48
+
49
+ ## Naming Conventions
50
+
51
+ ### API Clients
52
+ - **Class Name**: `{ApiName}API` (e.g., `DescribeAPI`, `BulkV2API`)
53
+ - **File**: `client.py`
54
+ - **Connection Property**: `sf.{api_name}` (e.g., `sf.describe`, `sf.bulk_v2`)
55
+
56
+ ### Types
57
+ - **File**: `types.py`
58
+ - **Naming**: Descriptive, specific to the API
59
+ - **Examples**: `SObjectDescribe`, `BulkJobInfo`, `OrganizationLimits`
60
+
61
+ ### Methods
62
+ - **Return Types**: Always use TypedDict for structured responses
63
+ - **Naming**: Clear, action-oriented (e.g., `get_job_status`, `describe_sobject`)
64
+ - **Parameters**: Use typed parameters with Optional where appropriate
65
+
66
+ ## Adding New APIs
67
+
68
+ When adding a new API (e.g., `query` for SOQL):
69
+
70
+ 1. **Create directory**: `api/query/`
71
+ 2. **Create files**:
72
+ ```python
73
+ # api/query/__init__.py
74
+ from .client import QueryAPI
75
+ from .types import QueryResult, QueryError
76
+ __all__ = ["QueryAPI", "QueryResult", "QueryError"]
77
+
78
+ # api/query/client.py
79
+ class QueryAPI:
80
+ def __init__(self, connection): ...
81
+ async def execute(self, soql: str) -> QueryResult: ...
82
+
83
+ # api/query/types.py
84
+ class QueryResult(TypedDict): ...
85
+ ```
86
+ 3. **Add to main API `__init__.py`**:
87
+ ```python
88
+ from .query import QueryAPI, QueryResult, QueryError
89
+ ```
90
+ 4. **Add to connection**:
91
+ ```python
92
+ @property
93
+ def query(self):
94
+ if self._query_api is None:
95
+ from .api.query import QueryAPI
96
+ self._query_api = QueryAPI(self)
97
+ return self._query_api
98
+ ```
99
+
100
+ ## Benefits
101
+
102
+ - **Organization**: Each API is self-contained
103
+ - **Type Safety**: Full TypedDict coverage
104
+ - **Discoverability**: Clear structure and naming
105
+ - **Maintainability**: Easy to add/modify APIs
106
+ - **Testing**: Each API can be tested independently
107
+ - **Documentation**: Types serve as API documentation
@@ -0,0 +1,65 @@
1
+ """
2
+ Salesforce API modules.
3
+
4
+ This package provides organized access to Salesforce APIs:
5
+ - describe: Object and organization describe/metadata
6
+ - bulk_v2: Bulk API v2 for large data operations
7
+ - query: SOQL queries and QueryMore operations
8
+ """
9
+
10
+ # Import API clients and types from organized submodules
11
+ from .bulk_v2 import (
12
+ BulkV2API,
13
+ BulkJobCreateRequest,
14
+ BulkJobInfo,
15
+ BulkJobStatus,
16
+ BulkJobError,
17
+ )
18
+ from .describe import (
19
+ DescribeAPI,
20
+ FieldInfo,
21
+ LimitInfo,
22
+ OrganizationInfo,
23
+ OrganizationLimits,
24
+ PicklistValue,
25
+ RecordTypeInfo,
26
+ SObjectDescribe,
27
+ SObjectInfo,
28
+ SalesforceAttributes,
29
+ )
30
+ from .query import (
31
+ QueryAPI,
32
+ QueryResult,
33
+ QueryResponse,
34
+ QueryAllResponse,
35
+ QueryMoreResponse,
36
+ QueryErrorResponse,
37
+ )
38
+
39
+ __all__ = [
40
+ # API Clients
41
+ "BulkV2API",
42
+ "DescribeAPI",
43
+ "QueryAPI",
44
+ # Bulk v2 Types
45
+ "BulkJobCreateRequest",
46
+ "BulkJobInfo",
47
+ "BulkJobStatus",
48
+ "BulkJobError",
49
+ # Describe Types
50
+ "FieldInfo",
51
+ "LimitInfo",
52
+ "OrganizationInfo",
53
+ "OrganizationLimits",
54
+ "PicklistValue",
55
+ "RecordTypeInfo",
56
+ "SObjectDescribe",
57
+ "SObjectInfo",
58
+ "SalesforceAttributes",
59
+ # Query Types
60
+ "QueryResult",
61
+ "QueryResponse",
62
+ "QueryAllResponse",
63
+ "QueryMoreResponse",
64
+ "QueryErrorResponse",
65
+ ]
@@ -0,0 +1,21 @@
1
+ """
2
+ Salesforce Bulk API v2.
3
+ """
4
+
5
+ from .client import BulkV2API
6
+ from .types import (
7
+ BulkJobCreateRequest,
8
+ BulkJobInfo,
9
+ BulkJobStatus,
10
+ BulkJobError,
11
+ )
12
+
13
+ __all__ = [
14
+ # API Client
15
+ "BulkV2API",
16
+ # Types
17
+ "BulkJobCreateRequest",
18
+ "BulkJobInfo",
19
+ "BulkJobStatus",
20
+ "BulkJobError",
21
+ ]
@@ -0,0 +1,200 @@
1
+ """
2
+ Salesforce Bulk API v2 methods.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Optional, Tuple, TYPE_CHECKING
9
+
10
+ from .types import (
11
+ BulkJobCreateRequest,
12
+ BulkJobInfo,
13
+ BulkJobStatus,
14
+ )
15
+
16
+ if TYPE_CHECKING:
17
+ from ...connection import SalesforceConnection
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class BulkV2API:
23
+ """Salesforce Bulk API v2 methods."""
24
+
25
+ def __init__(self, connection: "SalesforceConnection"):
26
+ self.connection = connection
27
+
28
+ def _get_base_url(self, api_version: Optional[str] = None) -> str:
29
+ """Get the base URL for Bulk API v2 requests."""
30
+ return self.connection.get_base_url(api_version)
31
+
32
+ def _get_job_url(self, job_id: str, api_version: Optional[str] = None) -> str:
33
+ """Get the URL for a specific bulk job."""
34
+ base_url = self._get_base_url(api_version)
35
+ return f"{base_url}/jobs/query/{job_id}"
36
+
37
+ def _get_job_results_url(
38
+ self, job_id: str, api_version: Optional[str] = None
39
+ ) -> str:
40
+ """Get the URL for fetching bulk job results."""
41
+ base_url = self._get_base_url(api_version)
42
+ return f"{base_url}/jobs/query/{job_id}/results"
43
+
44
+ def _get_jobs_url(self, api_version: Optional[str] = None) -> str:
45
+ """Get the URL for creating bulk jobs."""
46
+ base_url = self._get_base_url(api_version)
47
+ return f"{base_url}/jobs/query"
48
+
49
+ async def create_job(
50
+ self,
51
+ soql_query: str,
52
+ all_rows: bool = False,
53
+ api_version: Optional[str] = None,
54
+ ) -> BulkJobInfo:
55
+ """
56
+ Create a new bulk query job.
57
+
58
+ :param soql_query: The SOQL query to execute
59
+ :param all_rows: If True, includes deleted and archived records (queryAll)
60
+ :param api_version: API version to use (defaults to connection version)
61
+ :returns: Job information
62
+ """
63
+ job_url = self._get_jobs_url(api_version)
64
+ job_data: BulkJobCreateRequest = {
65
+ "operation": "queryAll" if all_rows else "query",
66
+ "query": soql_query,
67
+ "contentType": "CSV",
68
+ }
69
+
70
+ response = await self.connection.post(job_url, json=job_data)
71
+ response.raise_for_status()
72
+ job_info = response.json()
73
+
74
+ logger.info(
75
+ f"Created bulk job {job_info['id']} for query: {soql_query[:100]}..."
76
+ )
77
+ return job_info
78
+
79
+ async def get_job_status(
80
+ self,
81
+ job_id: str,
82
+ api_version: Optional[str] = None,
83
+ ) -> BulkJobStatus:
84
+ """
85
+ Get the status of a bulk job.
86
+
87
+ :param job_id: The job ID
88
+ :param api_version: API version to use (defaults to connection version)
89
+ :returns: Job status information
90
+ """
91
+ status_url = self._get_job_url(job_id, api_version)
92
+ response = await self.connection.get(status_url)
93
+ response.raise_for_status()
94
+ return response.json()
95
+
96
+ async def get_job_results(
97
+ self,
98
+ job_id: str,
99
+ locator: Optional[str] = None,
100
+ max_records: int = 10000,
101
+ api_version: Optional[str] = None,
102
+ ) -> Tuple[str, Optional[str]]:
103
+ """
104
+ Get results from a completed bulk job.
105
+
106
+ :param job_id: The job ID
107
+ :param locator: Query locator for pagination (optional)
108
+ :param max_records: Maximum number of records to fetch
109
+ :param api_version: API version to use (defaults to connection version)
110
+ :returns: Tuple of (CSV response text, next locator or None)
111
+ """
112
+ results_url = self._get_job_results_url(job_id, api_version)
113
+ params = {"maxRecords": max_records}
114
+ if locator:
115
+ params["locator"] = locator
116
+
117
+ response = await self.connection.get(results_url, params=params)
118
+ response.raise_for_status()
119
+
120
+ # Get next locator from headers
121
+ next_locator = response.headers.get("Sforce-Locator")
122
+ if next_locator == "null":
123
+ next_locator = None
124
+
125
+ return response.text, next_locator
126
+
127
+ async def wait_for_job_completion(
128
+ self,
129
+ job_id: str,
130
+ poll_interval: int = 5,
131
+ timeout: Optional[int] = None,
132
+ api_version: Optional[str] = None,
133
+ ) -> BulkJobStatus:
134
+ """
135
+ Wait for a bulk job to complete.
136
+
137
+ :param job_id: The job ID to monitor
138
+ :param poll_interval: Time in seconds between status checks
139
+ :param timeout: Maximum time to wait (None for no timeout)
140
+ :param api_version: API version to use (defaults to connection version)
141
+ :returns: Final job status
142
+ :raises TimeoutError: If job doesn't complete within timeout
143
+ :raises Exception: If job fails
144
+ """
145
+ logger.info(f"Waiting for job {job_id} to complete...")
146
+ start_time = time.time()
147
+
148
+ while True:
149
+ job_status = await self.get_job_status(job_id, api_version)
150
+ state = job_status["state"]
151
+
152
+ if state == "JobComplete":
153
+ total_records = job_status.get("numberRecordsProcessed", 0)
154
+ logger.info(
155
+ f"Job {job_id} completed successfully. Total records: {total_records}"
156
+ )
157
+ return job_status
158
+ elif state == "Failed":
159
+ raise Exception(f"Job {job_id} failed: {job_status}")
160
+ else:
161
+ # Check timeout
162
+ if timeout and (time.time() - start_time) > timeout:
163
+ raise TimeoutError(
164
+ f"Job {job_id} did not complete within {timeout} seconds"
165
+ )
166
+
167
+ # Job is still running
168
+ logger.debug(
169
+ f"Job {job_id} state: {state}, waiting {poll_interval}s..."
170
+ )
171
+ await asyncio.sleep(poll_interval)
172
+
173
+ async def execute_query(
174
+ self,
175
+ soql_query: str,
176
+ all_rows: bool = False,
177
+ poll_interval: int = 5,
178
+ timeout: Optional[int] = None,
179
+ api_version: Optional[str] = None,
180
+ ) -> BulkJobStatus:
181
+ """
182
+ Execute a SOQL query via Bulk API v2 and wait for completion.
183
+
184
+ This is a convenience method that creates a job and waits for it to complete.
185
+
186
+ :param soql_query: The SOQL query to execute
187
+ :param all_rows: If True, includes deleted and archived records
188
+ :param poll_interval: Time in seconds between status checks
189
+ :param timeout: Maximum time to wait (None for no timeout)
190
+ :param api_version: API version to use (defaults to connection version)
191
+ :returns: Final job status
192
+ """
193
+ # Create the job
194
+ job_info = await self.create_job(soql_query, all_rows, api_version)
195
+ job_id = job_info["id"]
196
+
197
+ # Wait for completion
198
+ return await self.wait_for_job_completion(
199
+ job_id, poll_interval, timeout, api_version
200
+ )
@@ -0,0 +1,71 @@
1
+ """
2
+ TypedDict definitions for Salesforce Bulk API v2 responses.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional, TypedDict
6
+
7
+
8
+ class BulkJobInfo(TypedDict):
9
+ """Bulk job information."""
10
+
11
+ id: str
12
+ operation: str
13
+ object: str
14
+ createdById: str
15
+ createdDate: str
16
+ systemModstamp: str
17
+ state: str
18
+ concurrencyMode: str
19
+ contentType: str
20
+ apiVersion: str
21
+ jobType: str
22
+ lineEnding: str
23
+ columnDelimiter: str
24
+
25
+
26
+ class BulkJobCreateRequest(TypedDict):
27
+ """Request payload for creating a bulk job."""
28
+
29
+ operation: str
30
+ query: str
31
+ contentType: str
32
+
33
+
34
+ class BulkJobStatus(TypedDict):
35
+ """Bulk job status information."""
36
+
37
+ id: str
38
+ operation: str
39
+ object: str
40
+ createdById: str
41
+ createdDate: str
42
+ systemModstamp: str
43
+ state: str
44
+ concurrencyMode: str
45
+ contentType: str
46
+ apiVersion: str
47
+ jobType: str
48
+ lineEnding: str
49
+ columnDelimiter: str
50
+ numberBatchesQueued: int
51
+ numberBatchesInProgress: int
52
+ numberBatchesCompleted: int
53
+ numberBatchesFailed: int
54
+ numberBatchesTotal: int
55
+ numberRequestsCompleted: int
56
+ numberRequestsFailed: int
57
+ numberRequestsTotal: int
58
+ numberRecordsProcessed: int
59
+ numberRecordsFailed: int
60
+ numberRetries: int
61
+ apiActiveProcessingTime: int
62
+ apexProcessingTime: int
63
+ totalProcessingTime: int
64
+
65
+
66
+ class BulkJobError(TypedDict):
67
+ """Bulk job error information."""
68
+
69
+ message: str
70
+ errorCode: str
71
+ fields: List[str]
@@ -0,0 +1,31 @@
1
+ """
2
+ Salesforce Describe API.
3
+ """
4
+
5
+ from .client import DescribeAPI
6
+ from .types import (
7
+ FieldInfo,
8
+ LimitInfo,
9
+ OrganizationInfo,
10
+ OrganizationLimits,
11
+ PicklistValue,
12
+ RecordTypeInfo,
13
+ SObjectDescribe,
14
+ SObjectInfo,
15
+ SalesforceAttributes,
16
+ )
17
+
18
+ __all__ = [
19
+ # API Client
20
+ "DescribeAPI",
21
+ # Types
22
+ "FieldInfo",
23
+ "LimitInfo",
24
+ "OrganizationInfo",
25
+ "OrganizationLimits",
26
+ "PicklistValue",
27
+ "RecordTypeInfo",
28
+ "SObjectDescribe",
29
+ "SObjectInfo",
30
+ "SalesforceAttributes",
31
+ ]
@@ -0,0 +1,94 @@
1
+ """
2
+ Salesforce Describe API methods.
3
+ """
4
+
5
+ import logging
6
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
7
+
8
+ from .types import (
9
+ OrganizationInfo,
10
+ OrganizationLimits,
11
+ SObjectDescribe,
12
+ SObjectInfo,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from ...connection import SalesforceConnection
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class DescribeAPI:
22
+ """Salesforce Describe API methods."""
23
+
24
+ def __init__(self, connection: "SalesforceConnection"):
25
+ self.connection = connection
26
+
27
+ async def sobject(
28
+ self, sobject_type: str, api_version: Optional[str] = None
29
+ ) -> SObjectDescribe:
30
+ """
31
+ Get metadata for a Salesforce object.
32
+
33
+ :param sobject_type: Name of the Salesforce object (e.g., 'Account', 'Contact')
34
+ :param api_version: API version to use (defaults to connection version)
35
+ :returns: Object metadata dictionary
36
+ """
37
+ url = self.connection.get_describe_url(sobject_type, api_version)
38
+ response = await self.connection.get(url)
39
+ response.raise_for_status()
40
+ return response.json()
41
+
42
+ async def list_sobjects(
43
+ self, api_version: Optional[str] = None
44
+ ) -> List[SObjectInfo]:
45
+ """
46
+ List all available Salesforce objects.
47
+
48
+ :param api_version: API version to use (defaults to connection version)
49
+ :returns: List of object metadata dictionaries
50
+ """
51
+ base_url = self.connection.get_base_url(api_version)
52
+ url = f"{base_url}/sobjects"
53
+ response = await self.connection.get(url)
54
+ response.raise_for_status()
55
+ data = response.json()
56
+ return data.get("sobjects", [])
57
+
58
+ async def get_limits(self, api_version: Optional[str] = None) -> OrganizationLimits:
59
+ """
60
+ Get organization limits.
61
+
62
+ :param api_version: API version to use (defaults to connection version)
63
+ :returns: Organization limits dictionary
64
+ """
65
+ base_url = self.connection.get_base_url(api_version)
66
+ url = f"{base_url}/limits"
67
+ response = await self.connection.get(url)
68
+ response.raise_for_status()
69
+ return response.json()
70
+
71
+ async def get_organization_info(
72
+ self, api_version: Optional[str] = None
73
+ ) -> OrganizationInfo:
74
+ """
75
+ Get organization information.
76
+
77
+ :param api_version: API version to use (defaults to connection version)
78
+ :returns: Organization info dictionary
79
+ """
80
+ # Query the Organization object for basic org info
81
+ base_url = self.connection.get_base_url(api_version)
82
+ url = f"{base_url}/query"
83
+ params = {
84
+ "q": "SELECT Id, Name, OrganizationType, InstanceName, IsSandbox FROM Organization LIMIT 1"
85
+ }
86
+ response = await self.connection.get(url, params=params)
87
+ response.raise_for_status()
88
+ data = response.json()
89
+
90
+ records = data.get("records", [])
91
+ if records:
92
+ return records[0]
93
+ else:
94
+ raise RuntimeError("Unable to retrieve organization information")