aio-sf 0.1.0b1__py3-none-any.whl → 0.1.0b3__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.
- {aio_salesforce → aio_sf}/__init__.py +8 -7
- {aio_salesforce → aio_sf}/api/__init__.py +36 -0
- aio_sf/api/auth/__init__.py +16 -0
- aio_sf/api/auth/base.py +43 -0
- aio_sf/api/auth/client_credentials.py +94 -0
- aio_sf/api/auth/refresh_token.py +114 -0
- aio_sf/api/auth/sfdx_cli.py +119 -0
- aio_sf/api/auth/static_token.py +31 -0
- {aio_salesforce → aio_sf}/api/bulk_v2/client.py +12 -12
- aio_sf/api/client.py +276 -0
- aio_sf/api/collections/__init__.py +33 -0
- aio_sf/api/collections/client.py +660 -0
- aio_sf/api/collections/types.py +70 -0
- {aio_salesforce → aio_sf}/api/describe/client.py +16 -16
- {aio_salesforce → aio_sf}/api/query/client.py +40 -18
- {aio_salesforce → aio_sf}/api/types.py +43 -2
- {aio_salesforce → aio_sf}/exporter/__init__.py +0 -4
- {aio_salesforce → aio_sf}/exporter/bulk_export.py +22 -43
- {aio_salesforce → aio_sf}/exporter/parquet_writer.py +4 -50
- {aio_sf-0.1.0b1.dist-info → aio_sf-0.1.0b3.dist-info}/METADATA +23 -37
- aio_sf-0.1.0b3.dist-info/RECORD +29 -0
- aio_salesforce/api/README.md +0 -107
- aio_salesforce/connection.py +0 -511
- aio_salesforce/exporter/parquet_writer.py.backup +0 -326
- aio_sf-0.1.0b1.dist-info/RECORD +0 -22
- {aio_salesforce → aio_sf}/api/bulk_v2/__init__.py +0 -0
- {aio_salesforce → aio_sf}/api/bulk_v2/types.py +0 -0
- {aio_salesforce → aio_sf}/api/describe/__init__.py +0 -0
- {aio_salesforce → aio_sf}/api/describe/types.py +0 -0
- {aio_salesforce → aio_sf}/api/query/__init__.py +0 -0
- {aio_salesforce → aio_sf}/api/query/types.py +0 -0
- {aio_sf-0.1.0b1.dist-info → aio_sf-0.1.0b3.dist-info}/WHEEL +0 -0
- {aio_sf-0.1.0b1.dist-info → aio_sf-0.1.0b3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
"""aio-salesforce: Async Salesforce library for Python with Bulk API 2.0 support."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.1.0b1"
|
|
4
3
|
__author__ = "Jonas"
|
|
5
4
|
__email__ = "charlie@callaway.cloud"
|
|
6
5
|
|
|
7
|
-
#
|
|
8
|
-
from .
|
|
9
|
-
|
|
6
|
+
# Client functionality
|
|
7
|
+
from .api.client import SalesforceClient # noqa: F401
|
|
8
|
+
from .api.auth import ( # noqa: F401
|
|
10
9
|
SalesforceAuthError,
|
|
11
10
|
AuthStrategy,
|
|
12
11
|
ClientCredentialsAuth,
|
|
13
12
|
RefreshTokenAuth,
|
|
14
13
|
StaticTokenAuth,
|
|
14
|
+
SfdxCliAuth,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
# Core package only exports
|
|
18
|
-
# Users import exporter functions directly: from
|
|
17
|
+
# Core package only exports client functionality
|
|
18
|
+
# Users import exporter functions directly: from aio_sf.exporter import bulk_query
|
|
19
19
|
|
|
20
20
|
__all__ = [
|
|
21
|
-
"
|
|
21
|
+
"SalesforceClient",
|
|
22
22
|
"SalesforceAuthError",
|
|
23
23
|
"AuthStrategy",
|
|
24
24
|
"ClientCredentialsAuth",
|
|
25
25
|
"RefreshTokenAuth",
|
|
26
26
|
"StaticTokenAuth",
|
|
27
|
+
"SfdxCliAuth",
|
|
27
28
|
]
|
|
@@ -5,6 +5,7 @@ This package provides organized access to Salesforce APIs:
|
|
|
5
5
|
- describe: Object and organization describe/metadata
|
|
6
6
|
- bulk_v2: Bulk API v2 for large data operations
|
|
7
7
|
- query: SOQL queries and QueryMore operations
|
|
8
|
+
- collections: Bulk record operations (insert, update, upsert, delete)
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
# Import API clients and types from organized submodules
|
|
@@ -15,6 +16,21 @@ from .bulk_v2 import (
|
|
|
15
16
|
BulkJobStatus,
|
|
16
17
|
BulkJobError,
|
|
17
18
|
)
|
|
19
|
+
from .collections import (
|
|
20
|
+
CollectionsAPI,
|
|
21
|
+
CollectionError,
|
|
22
|
+
CollectionRequest,
|
|
23
|
+
CollectionResult,
|
|
24
|
+
CollectionResponse,
|
|
25
|
+
InsertCollectionRequest,
|
|
26
|
+
UpdateCollectionRequest,
|
|
27
|
+
UpsertCollectionRequest,
|
|
28
|
+
DeleteCollectionRequest,
|
|
29
|
+
CollectionInsertResponse,
|
|
30
|
+
CollectionUpdateResponse,
|
|
31
|
+
CollectionUpsertResponse,
|
|
32
|
+
CollectionDeleteResponse,
|
|
33
|
+
)
|
|
18
34
|
from .describe import (
|
|
19
35
|
DescribeAPI,
|
|
20
36
|
FieldInfo,
|
|
@@ -25,7 +41,11 @@ from .describe import (
|
|
|
25
41
|
RecordTypeInfo,
|
|
26
42
|
SObjectDescribe,
|
|
27
43
|
SObjectInfo,
|
|
44
|
+
)
|
|
45
|
+
from .types import (
|
|
28
46
|
SalesforceAttributes,
|
|
47
|
+
SalesforceRecord,
|
|
48
|
+
GenericSalesforceRecord,
|
|
29
49
|
)
|
|
30
50
|
from .query import (
|
|
31
51
|
QueryAPI,
|
|
@@ -39,6 +59,7 @@ from .query import (
|
|
|
39
59
|
__all__ = [
|
|
40
60
|
# API Clients
|
|
41
61
|
"BulkV2API",
|
|
62
|
+
"CollectionsAPI",
|
|
42
63
|
"DescribeAPI",
|
|
43
64
|
"QueryAPI",
|
|
44
65
|
# Bulk v2 Types
|
|
@@ -46,6 +67,19 @@ __all__ = [
|
|
|
46
67
|
"BulkJobInfo",
|
|
47
68
|
"BulkJobStatus",
|
|
48
69
|
"BulkJobError",
|
|
70
|
+
# Collections Types
|
|
71
|
+
"CollectionError",
|
|
72
|
+
"CollectionRequest",
|
|
73
|
+
"CollectionResult",
|
|
74
|
+
"CollectionResponse",
|
|
75
|
+
"InsertCollectionRequest",
|
|
76
|
+
"UpdateCollectionRequest",
|
|
77
|
+
"UpsertCollectionRequest",
|
|
78
|
+
"DeleteCollectionRequest",
|
|
79
|
+
"CollectionInsertResponse",
|
|
80
|
+
"CollectionUpdateResponse",
|
|
81
|
+
"CollectionUpsertResponse",
|
|
82
|
+
"CollectionDeleteResponse",
|
|
49
83
|
# Describe Types
|
|
50
84
|
"FieldInfo",
|
|
51
85
|
"LimitInfo",
|
|
@@ -56,6 +90,8 @@ __all__ = [
|
|
|
56
90
|
"SObjectDescribe",
|
|
57
91
|
"SObjectInfo",
|
|
58
92
|
"SalesforceAttributes",
|
|
93
|
+
"SalesforceRecord",
|
|
94
|
+
"GenericSalesforceRecord",
|
|
59
95
|
# Query Types
|
|
60
96
|
"QueryResult",
|
|
61
97
|
"QueryResponse",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Authentication strategies for Salesforce API."""
|
|
2
|
+
|
|
3
|
+
from .base import AuthStrategy, SalesforceAuthError
|
|
4
|
+
from .client_credentials import ClientCredentialsAuth
|
|
5
|
+
from .refresh_token import RefreshTokenAuth
|
|
6
|
+
from .static_token import StaticTokenAuth
|
|
7
|
+
from .sfdx_cli import SfdxCliAuth
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AuthStrategy",
|
|
11
|
+
"SalesforceAuthError",
|
|
12
|
+
"ClientCredentialsAuth",
|
|
13
|
+
"RefreshTokenAuth",
|
|
14
|
+
"StaticTokenAuth",
|
|
15
|
+
"SfdxCliAuth",
|
|
16
|
+
]
|
aio_sf/api/auth/base.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Base authentication strategy and exceptions."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SalesforceAuthError(Exception):
|
|
11
|
+
"""Raised when authentication fails or tokens are invalid."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthStrategy(ABC):
|
|
17
|
+
"""Abstract base class for Salesforce authentication strategies."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, instance_url: str | None = None):
|
|
20
|
+
self.instance_url = instance_url.rstrip("/") if instance_url else None
|
|
21
|
+
self.access_token: Optional[str] = None
|
|
22
|
+
self.expires_at: Optional[int] = None
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def authenticate(self, http_client: httpx.AsyncClient) -> str:
|
|
26
|
+
"""Authenticate and return access token."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def refresh_if_needed(self, http_client: httpx.AsyncClient) -> str:
|
|
31
|
+
"""Refresh token if needed and return access token."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def can_refresh(self) -> bool:
|
|
36
|
+
"""Return True if this strategy can refresh expired tokens."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def is_token_expired(self) -> bool:
|
|
40
|
+
"""Check if the current token is expired."""
|
|
41
|
+
if not self.expires_at:
|
|
42
|
+
return False
|
|
43
|
+
return self.expires_at <= int(time.time())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""OAuth Client Credentials authentication strategy."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .base import AuthStrategy, SalesforceAuthError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClientCredentialsAuth(AuthStrategy):
|
|
15
|
+
"""OAuth Client Credentials authentication strategy."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, instance_url: str, client_id: str, client_secret: str):
|
|
18
|
+
super().__init__(instance_url)
|
|
19
|
+
self.client_id = client_id
|
|
20
|
+
self.client_secret = client_secret
|
|
21
|
+
|
|
22
|
+
async def authenticate(self, http_client: httpx.AsyncClient) -> str:
|
|
23
|
+
"""Authenticate using OAuth client credentials flow."""
|
|
24
|
+
logger.info("Getting Salesforce access token using client credentials")
|
|
25
|
+
|
|
26
|
+
oauth_url = urljoin(self.instance_url, "/services/oauth2/token")
|
|
27
|
+
data = {
|
|
28
|
+
"grant_type": "client_credentials",
|
|
29
|
+
"client_id": self.client_id,
|
|
30
|
+
"client_secret": self.client_secret,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
oauth_headers = {
|
|
34
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
35
|
+
"Accept": "application/json",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
response = await http_client.post(
|
|
40
|
+
oauth_url, data=data, headers=oauth_headers
|
|
41
|
+
)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
token_data = response.json()
|
|
44
|
+
self.access_token = token_data["access_token"]
|
|
45
|
+
|
|
46
|
+
# Get token expiration information
|
|
47
|
+
await self._get_token_expiration(http_client)
|
|
48
|
+
|
|
49
|
+
logger.info("Successfully obtained Salesforce access token")
|
|
50
|
+
return self.access_token
|
|
51
|
+
|
|
52
|
+
except httpx.HTTPError as e:
|
|
53
|
+
logger.error(f"HTTP error getting access token: {e}")
|
|
54
|
+
raise SalesforceAuthError(f"Authentication failed: {e}")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Unexpected error getting access token: {e}")
|
|
57
|
+
raise SalesforceAuthError(f"Authentication failed: {e}")
|
|
58
|
+
|
|
59
|
+
async def refresh_if_needed(self, http_client: httpx.AsyncClient) -> str:
|
|
60
|
+
"""Refresh token if needed (always re-authenticate for client credentials)."""
|
|
61
|
+
if self.access_token and not self.is_token_expired():
|
|
62
|
+
return self.access_token
|
|
63
|
+
return await self.authenticate(http_client)
|
|
64
|
+
|
|
65
|
+
def can_refresh(self) -> bool:
|
|
66
|
+
"""Client credentials can always re-authenticate."""
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
async def _get_token_expiration(self, http_client: httpx.AsyncClient) -> None:
|
|
70
|
+
"""Get token expiration time via introspection."""
|
|
71
|
+
introspect_url = urljoin(self.instance_url, "/services/oauth2/introspect")
|
|
72
|
+
introspect_data = {
|
|
73
|
+
"token": self.access_token,
|
|
74
|
+
"token_type_hint": "access_token",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
auth_string = base64.b64encode(
|
|
78
|
+
f"{self.client_id}:{self.client_secret}".encode("utf-8")
|
|
79
|
+
).decode("utf-8")
|
|
80
|
+
|
|
81
|
+
introspect_response = await http_client.post(
|
|
82
|
+
introspect_url,
|
|
83
|
+
data=introspect_data,
|
|
84
|
+
headers={
|
|
85
|
+
"Authorization": f"Basic {auth_string}",
|
|
86
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
87
|
+
"Accept": "application/json",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
introspect_response.raise_for_status()
|
|
91
|
+
introspect_data = introspect_response.json()
|
|
92
|
+
|
|
93
|
+
# Set expiration time with 30 second buffer
|
|
94
|
+
self.expires_at = introspect_data["exp"] - 30
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""OAuth Refresh Token authentication strategy."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .base import AuthStrategy, SalesforceAuthError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RefreshTokenAuth(AuthStrategy):
|
|
15
|
+
"""OAuth Refresh Token authentication strategy."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
instance_url: str,
|
|
20
|
+
access_token: str,
|
|
21
|
+
refresh_token: str,
|
|
22
|
+
client_id: str,
|
|
23
|
+
client_secret: str,
|
|
24
|
+
):
|
|
25
|
+
super().__init__(instance_url)
|
|
26
|
+
self.access_token = access_token
|
|
27
|
+
self.refresh_token = refresh_token
|
|
28
|
+
self.client_id = client_id
|
|
29
|
+
self.client_secret = client_secret
|
|
30
|
+
|
|
31
|
+
async def authenticate(self, http_client: httpx.AsyncClient) -> str:
|
|
32
|
+
"""Use the provided access token (refresh if needed)."""
|
|
33
|
+
if self.access_token and not self.is_token_expired():
|
|
34
|
+
return self.access_token
|
|
35
|
+
return await self._refresh_token(http_client)
|
|
36
|
+
|
|
37
|
+
async def refresh_if_needed(self, http_client: httpx.AsyncClient) -> str:
|
|
38
|
+
"""Refresh token if needed."""
|
|
39
|
+
if self.access_token and not self.is_token_expired():
|
|
40
|
+
return self.access_token
|
|
41
|
+
return await self._refresh_token(http_client)
|
|
42
|
+
|
|
43
|
+
def can_refresh(self) -> bool:
|
|
44
|
+
"""Refresh token auth can refresh tokens."""
|
|
45
|
+
return bool(self.refresh_token)
|
|
46
|
+
|
|
47
|
+
async def _refresh_token(self, http_client: httpx.AsyncClient) -> str:
|
|
48
|
+
"""Refresh the access token using the refresh token."""
|
|
49
|
+
logger.info("Refreshing Salesforce access token using refresh token")
|
|
50
|
+
|
|
51
|
+
oauth_url = urljoin(self.instance_url, "/services/oauth2/token")
|
|
52
|
+
data = {
|
|
53
|
+
"grant_type": "refresh_token",
|
|
54
|
+
"refresh_token": self.refresh_token,
|
|
55
|
+
"client_id": self.client_id,
|
|
56
|
+
"client_secret": self.client_secret,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
oauth_headers = {
|
|
60
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
61
|
+
"Accept": "application/json",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
response = await http_client.post(
|
|
66
|
+
oauth_url, data=data, headers=oauth_headers
|
|
67
|
+
)
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
token_data = response.json()
|
|
70
|
+
self.access_token = token_data["access_token"]
|
|
71
|
+
|
|
72
|
+
# Update refresh token if a new one is provided
|
|
73
|
+
if "refresh_token" in token_data:
|
|
74
|
+
self.refresh_token = token_data["refresh_token"]
|
|
75
|
+
|
|
76
|
+
# Get token expiration information
|
|
77
|
+
await self._get_token_expiration(http_client)
|
|
78
|
+
|
|
79
|
+
logger.info("Successfully refreshed Salesforce access token")
|
|
80
|
+
return self.access_token
|
|
81
|
+
|
|
82
|
+
except httpx.HTTPError as e:
|
|
83
|
+
logger.error(f"HTTP error refreshing access token: {e}")
|
|
84
|
+
raise SalesforceAuthError(f"Token refresh failed: {e}")
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.error(f"Unexpected error refreshing access token: {e}")
|
|
87
|
+
raise SalesforceAuthError(f"Token refresh failed: {e}")
|
|
88
|
+
|
|
89
|
+
async def _get_token_expiration(self, http_client: httpx.AsyncClient) -> None:
|
|
90
|
+
"""Get token expiration time via introspection."""
|
|
91
|
+
introspect_url = urljoin(self.instance_url, "/services/oauth2/introspect")
|
|
92
|
+
introspect_data = {
|
|
93
|
+
"token": self.access_token,
|
|
94
|
+
"token_type_hint": "access_token",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
auth_string = base64.b64encode(
|
|
98
|
+
f"{self.client_id}:{self.client_secret}".encode("utf-8")
|
|
99
|
+
).decode("utf-8")
|
|
100
|
+
|
|
101
|
+
introspect_response = await http_client.post(
|
|
102
|
+
introspect_url,
|
|
103
|
+
data=introspect_data,
|
|
104
|
+
headers={
|
|
105
|
+
"Authorization": f"Basic {auth_string}",
|
|
106
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
107
|
+
"Accept": "application/json",
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
introspect_response.raise_for_status()
|
|
111
|
+
introspect_data = introspect_response.json()
|
|
112
|
+
|
|
113
|
+
# Set expiration time with 30 second buffer
|
|
114
|
+
self.expires_at = introspect_data["exp"] - 30
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""SFDX CLI authentication strategy."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from typing import Dict, Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .base import AuthStrategy, SalesforceAuthError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SfdxCliAuth(AuthStrategy):
|
|
18
|
+
"""SFDX CLI authentication strategy using 'sf org display' command."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, username_or_alias: str):
|
|
21
|
+
"""
|
|
22
|
+
Initialize SFDX CLI authentication strategy.
|
|
23
|
+
|
|
24
|
+
:param username_or_alias: Salesforce org username or alias configured in SFDX CLI
|
|
25
|
+
"""
|
|
26
|
+
# We'll get the instance URL from the CLI command
|
|
27
|
+
super().__init__(None)
|
|
28
|
+
self.username_or_alias = username_or_alias
|
|
29
|
+
self._ansi_escape = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]")
|
|
30
|
+
|
|
31
|
+
async def authenticate(self, http_client: httpx.AsyncClient) -> str:
|
|
32
|
+
"""Get access token from SFDX CLI."""
|
|
33
|
+
logger.info(
|
|
34
|
+
f"Getting Salesforce access token from SFDX CLI for {self.username_or_alias}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
# Execute the SFDX command asynchronously
|
|
39
|
+
token_info = await self._execute_sfdx_command()
|
|
40
|
+
|
|
41
|
+
# Extract token and instance URL
|
|
42
|
+
self.access_token = token_info["accessToken"]
|
|
43
|
+
self.instance_url = token_info["instanceUrl"].rstrip("/")
|
|
44
|
+
|
|
45
|
+
# SFDX tokens typically have a reasonable expiration time
|
|
46
|
+
# We'll set a conservative 1-hour expiration to trigger refresh
|
|
47
|
+
self.expires_at = int(time.time()) + 3600
|
|
48
|
+
|
|
49
|
+
logger.info("Successfully obtained Salesforce access token from SFDX CLI")
|
|
50
|
+
return self.access_token
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.error(f"Error getting access token from SFDX CLI: {e}")
|
|
54
|
+
raise SalesforceAuthError(f"SFDX CLI authentication failed: {e}")
|
|
55
|
+
|
|
56
|
+
async def refresh_if_needed(self, http_client: httpx.AsyncClient) -> str:
|
|
57
|
+
"""Refresh token if needed (re-execute CLI command)."""
|
|
58
|
+
if self.access_token and not self.is_token_expired():
|
|
59
|
+
return self.access_token
|
|
60
|
+
return await self.authenticate(http_client)
|
|
61
|
+
|
|
62
|
+
def can_refresh(self) -> bool:
|
|
63
|
+
"""SFDX CLI can always re-authenticate."""
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
async def _execute_sfdx_command(self) -> Dict[str, Any]:
|
|
67
|
+
"""
|
|
68
|
+
Execute the SFDX CLI command asynchronously and parse the result.
|
|
69
|
+
|
|
70
|
+
:returns: Dictionary containing accessToken and instanceUrl
|
|
71
|
+
:raises: SalesforceAuthError if command fails or output is invalid
|
|
72
|
+
"""
|
|
73
|
+
cmd = f"sf org display -o {self.username_or_alias} --json"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Run the command asynchronously
|
|
77
|
+
process = await asyncio.create_subprocess_shell(
|
|
78
|
+
cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
stdout, stderr = await process.communicate()
|
|
82
|
+
|
|
83
|
+
if process.returncode != 0:
|
|
84
|
+
error_msg = stderr.decode().strip() if stderr else "Unknown error"
|
|
85
|
+
raise SalesforceAuthError(f"SFDX command failed: {error_msg}")
|
|
86
|
+
|
|
87
|
+
# Clean ANSI escape sequences from output
|
|
88
|
+
cleaned_output = self._ansi_escape.sub("", stdout.decode())
|
|
89
|
+
|
|
90
|
+
# Parse JSON response
|
|
91
|
+
try:
|
|
92
|
+
sfdx_info = json.loads(cleaned_output)
|
|
93
|
+
except json.JSONDecodeError as e:
|
|
94
|
+
raise SalesforceAuthError(f"Invalid JSON response from SFDX CLI: {e}")
|
|
95
|
+
|
|
96
|
+
# Validate response structure
|
|
97
|
+
if "result" not in sfdx_info:
|
|
98
|
+
raise SalesforceAuthError("SFDX CLI response missing 'result' field")
|
|
99
|
+
|
|
100
|
+
result = sfdx_info["result"]
|
|
101
|
+
|
|
102
|
+
if "accessToken" not in result:
|
|
103
|
+
raise SalesforceAuthError(
|
|
104
|
+
"SFDX CLI response missing 'accessToken' field"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if "instanceUrl" not in result:
|
|
108
|
+
raise SalesforceAuthError(
|
|
109
|
+
"SFDX CLI response missing 'instanceUrl' field"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
except asyncio.TimeoutError:
|
|
115
|
+
raise SalesforceAuthError("SFDX CLI command timed out")
|
|
116
|
+
except FileNotFoundError:
|
|
117
|
+
raise SalesforceAuthError(
|
|
118
|
+
"SFDX CLI not found. Please ensure Salesforce CLI is installed and in PATH"
|
|
119
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Static access token authentication strategy."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .base import AuthStrategy, SalesforceAuthError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StaticTokenAuth(AuthStrategy):
|
|
9
|
+
"""Static access token authentication strategy (no refresh capability)."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, instance_url: str, access_token: str):
|
|
12
|
+
super().__init__(instance_url)
|
|
13
|
+
self.access_token = access_token
|
|
14
|
+
|
|
15
|
+
async def authenticate(self, http_client: httpx.AsyncClient) -> str:
|
|
16
|
+
"""Return the static access token."""
|
|
17
|
+
if not self.access_token:
|
|
18
|
+
raise SalesforceAuthError("No access token available")
|
|
19
|
+
return self.access_token
|
|
20
|
+
|
|
21
|
+
async def refresh_if_needed(self, http_client: httpx.AsyncClient) -> str:
|
|
22
|
+
"""Cannot refresh static tokens."""
|
|
23
|
+
if self.is_token_expired():
|
|
24
|
+
raise SalesforceAuthError(
|
|
25
|
+
"Access token has expired and no refresh capability is available."
|
|
26
|
+
)
|
|
27
|
+
return await self.authenticate(http_client)
|
|
28
|
+
|
|
29
|
+
def can_refresh(self) -> bool:
|
|
30
|
+
"""Static tokens cannot be refreshed."""
|
|
31
|
+
return False
|
|
@@ -14,7 +14,7 @@ from .types import (
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
|
-
from
|
|
17
|
+
from ..client import SalesforceClient
|
|
18
18
|
|
|
19
19
|
logger = logging.getLogger(__name__)
|
|
20
20
|
|
|
@@ -22,12 +22,12 @@ logger = logging.getLogger(__name__)
|
|
|
22
22
|
class BulkV2API:
|
|
23
23
|
"""Salesforce Bulk API v2 methods."""
|
|
24
24
|
|
|
25
|
-
def __init__(self,
|
|
26
|
-
self.
|
|
25
|
+
def __init__(self, client: "SalesforceClient"):
|
|
26
|
+
self.client = client
|
|
27
27
|
|
|
28
28
|
def _get_base_url(self, api_version: Optional[str] = None) -> str:
|
|
29
29
|
"""Get the base URL for Bulk API v2 requests."""
|
|
30
|
-
return self.
|
|
30
|
+
return self.client.get_base_url(api_version)
|
|
31
31
|
|
|
32
32
|
def _get_job_url(self, job_id: str, api_version: Optional[str] = None) -> str:
|
|
33
33
|
"""Get the URL for a specific bulk job."""
|
|
@@ -57,7 +57,7 @@ class BulkV2API:
|
|
|
57
57
|
|
|
58
58
|
:param soql_query: The SOQL query to execute
|
|
59
59
|
:param all_rows: If True, includes deleted and archived records (queryAll)
|
|
60
|
-
:param api_version: API version to use (defaults to
|
|
60
|
+
:param api_version: API version to use (defaults to client version)
|
|
61
61
|
:returns: Job information
|
|
62
62
|
"""
|
|
63
63
|
job_url = self._get_jobs_url(api_version)
|
|
@@ -67,7 +67,7 @@ class BulkV2API:
|
|
|
67
67
|
"contentType": "CSV",
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
response = await self.
|
|
70
|
+
response = await self.client.post(job_url, json=job_data)
|
|
71
71
|
response.raise_for_status()
|
|
72
72
|
job_info = response.json()
|
|
73
73
|
|
|
@@ -85,11 +85,11 @@ class BulkV2API:
|
|
|
85
85
|
Get the status of a bulk job.
|
|
86
86
|
|
|
87
87
|
:param job_id: The job ID
|
|
88
|
-
:param api_version: API version to use (defaults to
|
|
88
|
+
:param api_version: API version to use (defaults to client version)
|
|
89
89
|
:returns: Job status information
|
|
90
90
|
"""
|
|
91
91
|
status_url = self._get_job_url(job_id, api_version)
|
|
92
|
-
response = await self.
|
|
92
|
+
response = await self.client.get(status_url)
|
|
93
93
|
response.raise_for_status()
|
|
94
94
|
return response.json()
|
|
95
95
|
|
|
@@ -106,7 +106,7 @@ class BulkV2API:
|
|
|
106
106
|
:param job_id: The job ID
|
|
107
107
|
:param locator: Query locator for pagination (optional)
|
|
108
108
|
:param max_records: Maximum number of records to fetch
|
|
109
|
-
:param api_version: API version to use (defaults to
|
|
109
|
+
:param api_version: API version to use (defaults to client version)
|
|
110
110
|
:returns: Tuple of (CSV response text, next locator or None)
|
|
111
111
|
"""
|
|
112
112
|
results_url = self._get_job_results_url(job_id, api_version)
|
|
@@ -114,7 +114,7 @@ class BulkV2API:
|
|
|
114
114
|
if locator:
|
|
115
115
|
params["locator"] = locator
|
|
116
116
|
|
|
117
|
-
response = await self.
|
|
117
|
+
response = await self.client.get(results_url, params=params)
|
|
118
118
|
response.raise_for_status()
|
|
119
119
|
|
|
120
120
|
# Get next locator from headers
|
|
@@ -137,7 +137,7 @@ class BulkV2API:
|
|
|
137
137
|
:param job_id: The job ID to monitor
|
|
138
138
|
:param poll_interval: Time in seconds between status checks
|
|
139
139
|
:param timeout: Maximum time to wait (None for no timeout)
|
|
140
|
-
:param api_version: API version to use (defaults to
|
|
140
|
+
:param api_version: API version to use (defaults to client version)
|
|
141
141
|
:returns: Final job status
|
|
142
142
|
:raises TimeoutError: If job doesn't complete within timeout
|
|
143
143
|
:raises Exception: If job fails
|
|
@@ -187,7 +187,7 @@ class BulkV2API:
|
|
|
187
187
|
:param all_rows: If True, includes deleted and archived records
|
|
188
188
|
:param poll_interval: Time in seconds between status checks
|
|
189
189
|
:param timeout: Maximum time to wait (None for no timeout)
|
|
190
|
-
:param api_version: API version to use (defaults to
|
|
190
|
+
:param api_version: API version to use (defaults to client version)
|
|
191
191
|
:returns: Final job status
|
|
192
192
|
"""
|
|
193
193
|
# Create the job
|