cloudy-salesforce 0.2.0__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.
- cloudy_salesforce/__init__.py +33 -0
- cloudy_salesforce/client/__init__.py +13 -0
- cloudy_salesforce/client/auth.py +171 -0
- cloudy_salesforce/client/config.py +166 -0
- cloudy_salesforce/client/salesforceclient.py +201 -0
- cloudy_salesforce/collections/__init__.py +35 -0
- cloudy_salesforce/collections/crud_operations.py +353 -0
- cloudy_salesforce/collections/return_functions.py +60 -0
- cloudy_salesforce/collections/serialize.py +89 -0
- cloudy_salesforce/collections/types.py +13 -0
- cloudy_salesforce/exceptions.py +28 -0
- cloudy_salesforce/generator/__init__.py +5 -0
- cloudy_salesforce/generator/cli.py +112 -0
- cloudy_salesforce/generator/generator.py +238 -0
- cloudy_salesforce/generator/templates/sobject.jinja2 +27 -0
- cloudy_salesforce/py.typed +0 -0
- cloudy_salesforce/query/__init__.py +9 -0
- cloudy_salesforce/query/query.py +107 -0
- cloudy_salesforce/query/return_functions.py +5 -0
- cloudy_salesforce/sobjects/__init__.py +13 -0
- cloudy_salesforce/sobjects/sobject.py +203 -0
- cloudy_salesforce-0.2.0.dist-info/METADATA +242 -0
- cloudy_salesforce-0.2.0.dist-info/RECORD +26 -0
- cloudy_salesforce-0.2.0.dist-info/WHEEL +4 -0
- cloudy_salesforce-0.2.0.dist-info/entry_points.txt +2 -0
- cloudy_salesforce-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Typed Salesforce client with query, CRUD, and codegen."""
|
|
2
|
+
|
|
3
|
+
from cloudy_salesforce.client import (
|
|
4
|
+
JwtBearerAuthentication,
|
|
5
|
+
SalesforceClient,
|
|
6
|
+
SessionAuthentication,
|
|
7
|
+
UsernamePasswordAuthentication,
|
|
8
|
+
)
|
|
9
|
+
from cloudy_salesforce.collections import DmlResult, delete, insert, update, upsert
|
|
10
|
+
from cloudy_salesforce.exceptions import SalesforceError
|
|
11
|
+
from cloudy_salesforce.query import query, soql_query
|
|
12
|
+
from cloudy_salesforce.sobjects import SObjects, parse_sobject_response, sobject
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SalesforceClient",
|
|
18
|
+
"UsernamePasswordAuthentication",
|
|
19
|
+
"JwtBearerAuthentication",
|
|
20
|
+
"SessionAuthentication",
|
|
21
|
+
"query",
|
|
22
|
+
"soql_query",
|
|
23
|
+
"insert",
|
|
24
|
+
"update",
|
|
25
|
+
"upsert",
|
|
26
|
+
"delete",
|
|
27
|
+
"DmlResult",
|
|
28
|
+
"SalesforceError",
|
|
29
|
+
"sobject",
|
|
30
|
+
"SObjects",
|
|
31
|
+
"parse_sobject_response",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .auth import (
|
|
2
|
+
JwtBearerAuthentication,
|
|
3
|
+
SessionAuthentication,
|
|
4
|
+
UsernamePasswordAuthentication,
|
|
5
|
+
)
|
|
6
|
+
from .salesforceclient import SalesforceClient
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SalesforceClient",
|
|
10
|
+
"UsernamePasswordAuthentication",
|
|
11
|
+
"JwtBearerAuthentication",
|
|
12
|
+
"SessionAuthentication",
|
|
13
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from xml.sax.saxutils import escape
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
import requests
|
|
9
|
+
from requests.exceptions import HTTPError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseAuthentication(ABC):
|
|
17
|
+
def __init__(self, session: requests.Session, instance_url: str):
|
|
18
|
+
self.session = session
|
|
19
|
+
self.instance_url = instance_url
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def authenticate(self) -> tuple[requests.Session, str]:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def get_headers(access_token: str) -> dict[str, str]:
|
|
27
|
+
return {
|
|
28
|
+
"Authorization": f"Bearer {access_token}",
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UsernamePasswordAuthentication(BaseAuthentication):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
username: str,
|
|
37
|
+
password: str,
|
|
38
|
+
security_token: str,
|
|
39
|
+
login_url: str = "https://login.salesforce.com",
|
|
40
|
+
api_version: str = "v61.0",
|
|
41
|
+
):
|
|
42
|
+
self.username = username
|
|
43
|
+
self.password = password
|
|
44
|
+
self.security_token = security_token
|
|
45
|
+
self.login_url = login_url
|
|
46
|
+
self.api_version = api_version
|
|
47
|
+
session, instance_url = self.authenticate()
|
|
48
|
+
super().__init__(session, instance_url)
|
|
49
|
+
|
|
50
|
+
def authenticate(self) -> tuple[requests.Session, str]:
|
|
51
|
+
session = requests.Session()
|
|
52
|
+
soap_version = self.api_version.removeprefix("v")
|
|
53
|
+
auth_url = f"{self.login_url}/services/Soap/u/{soap_version}"
|
|
54
|
+
headers = {"Content-Type": "text/xml", "SOAPAction": "login"}
|
|
55
|
+
escaped_username = escape(self.username)
|
|
56
|
+
escaped_password = escape(f"{self.password}{self.security_token}")
|
|
57
|
+
soap_body = f"""
|
|
58
|
+
<env:Envelope xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:env="http://schemas.xmlsoap.org/soap/envelope/">
|
|
59
|
+
<env:Body>
|
|
60
|
+
<n1:login xmlns:n1="urn:partner.soap.sforce.com">
|
|
61
|
+
<n1:username>{escaped_username}</n1:username>
|
|
62
|
+
<n1:password>{escaped_password}</n1:password>
|
|
63
|
+
</n1:login>
|
|
64
|
+
</env:Body>
|
|
65
|
+
</env:Envelope>
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
response = session.post(
|
|
69
|
+
auth_url, headers=headers, data=soap_body, timeout=30
|
|
70
|
+
)
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
response_content = response.content.decode("utf-8")
|
|
73
|
+
if "faultstring" in response_content:
|
|
74
|
+
raise Exception(f"SOAP Fault: {response_content}")
|
|
75
|
+
access_token = self._extract_access_token(response_content)
|
|
76
|
+
instance_url = self._extract_instance_url(response_content)
|
|
77
|
+
logger.info("Authentication successful")
|
|
78
|
+
session.headers.update(self.get_headers(access_token))
|
|
79
|
+
return session, instance_url
|
|
80
|
+
except HTTPError as http_err:
|
|
81
|
+
logger.error(f"HTTP error occurred: {http_err}")
|
|
82
|
+
raise
|
|
83
|
+
except Exception as err:
|
|
84
|
+
logger.error(f"Other error occurred: {err}")
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
def _extract_tag(self, response_content: str, tag: str) -> str:
|
|
88
|
+
start_tag = f"<{tag}>"
|
|
89
|
+
end_tag = f"</{tag}>"
|
|
90
|
+
start_index = response_content.find(start_tag)
|
|
91
|
+
end_index = response_content.find(end_tag)
|
|
92
|
+
if start_index == -1 or end_index == -1:
|
|
93
|
+
raise ValueError(f"SOAP response missing <{tag}>")
|
|
94
|
+
return response_content[start_index + len(start_tag) : end_index]
|
|
95
|
+
|
|
96
|
+
def _extract_access_token(self, response_content: str) -> str:
|
|
97
|
+
return self._extract_tag(response_content, "sessionId")
|
|
98
|
+
|
|
99
|
+
def _extract_instance_url(self, response_content: str) -> str:
|
|
100
|
+
server_url = self._extract_tag(response_content, "serverUrl")
|
|
101
|
+
return server_url.split("/services")[0]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SessionAuthentication(BaseAuthentication):
|
|
105
|
+
def __init__(self, access_token: str, instance_url: str):
|
|
106
|
+
self.access_token = access_token
|
|
107
|
+
session = requests.Session()
|
|
108
|
+
session.headers.update(self.get_headers(access_token))
|
|
109
|
+
super().__init__(session, instance_url)
|
|
110
|
+
|
|
111
|
+
def authenticate(self) -> tuple[requests.Session, str]:
|
|
112
|
+
return self.session, self.instance_url
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class JwtBearerAuthentication(BaseAuthentication):
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
client_id: str,
|
|
119
|
+
username: str,
|
|
120
|
+
private_key: str | None = None,
|
|
121
|
+
*,
|
|
122
|
+
private_key_path: str | None = None,
|
|
123
|
+
login_url: str = "https://login.salesforce.com",
|
|
124
|
+
):
|
|
125
|
+
self.client_id = client_id
|
|
126
|
+
self.username = username
|
|
127
|
+
self.login_url = login_url
|
|
128
|
+
if private_key is not None:
|
|
129
|
+
self.private_key = private_key
|
|
130
|
+
elif private_key_path is not None:
|
|
131
|
+
self.private_key = Path(private_key_path).read_text(encoding="utf-8")
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError("Either private_key or private_key_path is required")
|
|
134
|
+
session, instance_url = self.authenticate()
|
|
135
|
+
super().__init__(session, instance_url)
|
|
136
|
+
|
|
137
|
+
def _build_assertion(self) -> str:
|
|
138
|
+
payload = {
|
|
139
|
+
"iss": self.client_id,
|
|
140
|
+
"sub": self.username,
|
|
141
|
+
"aud": self.login_url,
|
|
142
|
+
"exp": int(time.time()) + 180,
|
|
143
|
+
}
|
|
144
|
+
return jwt.encode(payload, self.private_key, algorithm="RS256")
|
|
145
|
+
|
|
146
|
+
def authenticate(self) -> tuple[requests.Session, str]:
|
|
147
|
+
session = requests.Session()
|
|
148
|
+
token_url = f"{self.login_url}/services/oauth2/token"
|
|
149
|
+
assertion = self._build_assertion()
|
|
150
|
+
try:
|
|
151
|
+
response = session.post(
|
|
152
|
+
token_url,
|
|
153
|
+
data={
|
|
154
|
+
"grant_type": JWT_BEARER_GRANT_TYPE,
|
|
155
|
+
"assertion": assertion,
|
|
156
|
+
},
|
|
157
|
+
timeout=30,
|
|
158
|
+
)
|
|
159
|
+
response.raise_for_status()
|
|
160
|
+
token_data = response.json()
|
|
161
|
+
access_token = token_data["access_token"]
|
|
162
|
+
instance_url = token_data["instance_url"]
|
|
163
|
+
logger.info("JWT bearer authentication successful")
|
|
164
|
+
session.headers.update(self.get_headers(access_token))
|
|
165
|
+
return session, instance_url
|
|
166
|
+
except HTTPError as http_err:
|
|
167
|
+
logger.error(f"HTTP error occurred: {http_err}")
|
|
168
|
+
raise
|
|
169
|
+
except Exception as err:
|
|
170
|
+
logger.error(f"Other error occurred: {err}")
|
|
171
|
+
raise
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Load `.cloudy_config` and build authentication from alias definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dotenv import find_dotenv, load_dotenv
|
|
10
|
+
|
|
11
|
+
from .auth import (
|
|
12
|
+
BaseAuthentication,
|
|
13
|
+
JwtBearerAuthentication,
|
|
14
|
+
SessionAuthentication,
|
|
15
|
+
UsernamePasswordAuthentication,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
DEFAULT_CLOUDY_CONFIG_PATH = ".cloudy_config"
|
|
19
|
+
|
|
20
|
+
DEFAULT_CLOUDY_CONFIG_EXAMPLE = """{
|
|
21
|
+
"auth": {
|
|
22
|
+
"default_alias": "prod",
|
|
23
|
+
"aliases": {
|
|
24
|
+
"prod": {
|
|
25
|
+
"type": "basic",
|
|
26
|
+
"login_url": "https://login.salesforce.com",
|
|
27
|
+
"credentials": {
|
|
28
|
+
"username": "SF_USERNAME",
|
|
29
|
+
"password": "SF_PASSWORD",
|
|
30
|
+
"security_token": "SF_SECURITY_TOKEN"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"sandbox": {
|
|
34
|
+
"type": "basic",
|
|
35
|
+
"sandbox": true,
|
|
36
|
+
"credentials": {
|
|
37
|
+
"username": "SF_USERNAME",
|
|
38
|
+
"password": "SF_PASSWORD",
|
|
39
|
+
"security_token": "SF_SECURITY_TOKEN"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"jwt": {
|
|
43
|
+
"type": "jwt",
|
|
44
|
+
"login_url": "https://login.salesforce.com",
|
|
45
|
+
"credentials": {
|
|
46
|
+
"client_id": "SF_CLIENT_ID",
|
|
47
|
+
"username": "SF_USERNAME",
|
|
48
|
+
"private_key": "SF_PRIVATE_KEY"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"session": {
|
|
52
|
+
"type": "session",
|
|
53
|
+
"credentials": {
|
|
54
|
+
"access_token": "SF_ACCESS_TOKEN",
|
|
55
|
+
"instance_url": "SF_INSTANCE_URL"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"sobjects": ["Account", "Contact", "Opportunity"]
|
|
61
|
+
}
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
DEFAULT_ENV_EXAMPLE = """SF_USERNAME=
|
|
65
|
+
SF_PASSWORD=
|
|
66
|
+
SF_SECURITY_TOKEN=
|
|
67
|
+
SF_CLIENT_ID=
|
|
68
|
+
SF_PRIVATE_KEY=
|
|
69
|
+
SF_ACCESS_TOKEN=
|
|
70
|
+
SF_INSTANCE_URL=
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_login_url(alias: dict) -> str:
|
|
75
|
+
if "login_url" in alias:
|
|
76
|
+
return alias["login_url"]
|
|
77
|
+
if alias.get("sandbox"):
|
|
78
|
+
return "https://test.salesforce.com"
|
|
79
|
+
return "https://login.salesforce.com"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_cloudy_config(path: str = DEFAULT_CLOUDY_CONFIG_PATH) -> dict:
|
|
83
|
+
config_path = Path(path)
|
|
84
|
+
try:
|
|
85
|
+
with config_path.open("r", encoding="utf-8") as file:
|
|
86
|
+
config = json.load(file)
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
raise FileNotFoundError(f"{path} not found") from None
|
|
89
|
+
|
|
90
|
+
if not config or "auth" not in config:
|
|
91
|
+
raise ValueError("Invalid .cloudy_config: missing auth section")
|
|
92
|
+
|
|
93
|
+
return config
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_alias(config: dict, alias_name: str) -> dict:
|
|
97
|
+
auth_details = config["auth"]
|
|
98
|
+
if alias_name == "default":
|
|
99
|
+
alias_name = auth_details["default_alias"]
|
|
100
|
+
|
|
101
|
+
aliases = auth_details.get("aliases")
|
|
102
|
+
if not aliases or alias_name not in aliases:
|
|
103
|
+
raise ValueError(f"Unknown auth alias: {alias_name}")
|
|
104
|
+
|
|
105
|
+
return aliases[alias_name]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require_env(var_name: str) -> str:
|
|
109
|
+
value = os.getenv(var_name)
|
|
110
|
+
if value is None:
|
|
111
|
+
raise ValueError(f"Missing required environment variable: {var_name}")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_auth_from_alias(
|
|
116
|
+
alias: dict,
|
|
117
|
+
*,
|
|
118
|
+
api_version: str | None = None,
|
|
119
|
+
) -> BaseAuthentication:
|
|
120
|
+
if alias["type"] == "basic":
|
|
121
|
+
load_dotenv(dotenv_path=find_dotenv(raise_error_if_not_found=True))
|
|
122
|
+
credentials = alias["credentials"]
|
|
123
|
+
username_var = credentials["username"]
|
|
124
|
+
password_var = credentials["password"]
|
|
125
|
+
token_var = credentials["security_token"]
|
|
126
|
+
|
|
127
|
+
username = _require_env(username_var)
|
|
128
|
+
password = _require_env(password_var)
|
|
129
|
+
security_token = _require_env(token_var)
|
|
130
|
+
|
|
131
|
+
kwargs: dict[str, str] = {
|
|
132
|
+
"username": username,
|
|
133
|
+
"password": password,
|
|
134
|
+
"security_token": security_token,
|
|
135
|
+
"login_url": get_login_url(alias),
|
|
136
|
+
}
|
|
137
|
+
effective_api_version = api_version or alias.get("api_version")
|
|
138
|
+
if effective_api_version is not None:
|
|
139
|
+
kwargs["api_version"] = effective_api_version
|
|
140
|
+
return UsernamePasswordAuthentication(**kwargs)
|
|
141
|
+
|
|
142
|
+
if alias["type"] == "jwt":
|
|
143
|
+
load_dotenv(dotenv_path=find_dotenv(raise_error_if_not_found=True))
|
|
144
|
+
credentials = alias["credentials"]
|
|
145
|
+
client_id = _require_env(credentials["client_id"])
|
|
146
|
+
username = _require_env(credentials["username"])
|
|
147
|
+
|
|
148
|
+
jwt_kwargs: dict[str, str] = {
|
|
149
|
+
"client_id": client_id,
|
|
150
|
+
"username": username,
|
|
151
|
+
"login_url": get_login_url(alias),
|
|
152
|
+
}
|
|
153
|
+
if "private_key_path" in credentials:
|
|
154
|
+
jwt_kwargs["private_key_path"] = credentials["private_key_path"]
|
|
155
|
+
else:
|
|
156
|
+
jwt_kwargs["private_key"] = _require_env(credentials["private_key"])
|
|
157
|
+
return JwtBearerAuthentication(**jwt_kwargs)
|
|
158
|
+
|
|
159
|
+
if alias["type"] == "session":
|
|
160
|
+
load_dotenv(dotenv_path=find_dotenv(raise_error_if_not_found=True))
|
|
161
|
+
credentials = alias["credentials"]
|
|
162
|
+
access_token = _require_env(credentials["access_token"])
|
|
163
|
+
instance_url = _require_env(credentials["instance_url"])
|
|
164
|
+
return SessionAuthentication(access_token, instance_url)
|
|
165
|
+
|
|
166
|
+
raise ValueError(f"Auth type not supported yet: {alias['type']}")
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, NoReturn
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from requests.exceptions import HTTPError
|
|
7
|
+
|
|
8
|
+
from cloudy_salesforce.exceptions import SalesforceError
|
|
9
|
+
|
|
10
|
+
from .auth import BaseAuthentication
|
|
11
|
+
from .config import build_auth_from_alias, load_cloudy_config, resolve_alias
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_RATE_LIMIT_RETRY_DELAYS = (0.5, 1.0)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _raise_salesforce_error(http_err: HTTPError) -> NoReturn:
|
|
19
|
+
"""Convert an HTTPError into a structured SalesforceError."""
|
|
20
|
+
response = http_err.response
|
|
21
|
+
status_code = response.status_code if response is not None else None
|
|
22
|
+
|
|
23
|
+
if response is None:
|
|
24
|
+
raise SalesforceError(
|
|
25
|
+
str(http_err),
|
|
26
|
+
status_code=status_code,
|
|
27
|
+
) from http_err
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
body = response.json()
|
|
31
|
+
except ValueError:
|
|
32
|
+
raise SalesforceError(
|
|
33
|
+
response.text or str(http_err),
|
|
34
|
+
status_code=status_code,
|
|
35
|
+
response_body=response.text,
|
|
36
|
+
) from http_err
|
|
37
|
+
|
|
38
|
+
if isinstance(body, list) and body and isinstance(body[0], dict):
|
|
39
|
+
entry = body[0]
|
|
40
|
+
raise SalesforceError(
|
|
41
|
+
entry.get("message", str(http_err)),
|
|
42
|
+
status_code=status_code,
|
|
43
|
+
error_code=entry.get("errorCode"),
|
|
44
|
+
response_body=body,
|
|
45
|
+
) from http_err
|
|
46
|
+
|
|
47
|
+
if isinstance(body, dict) and ("errorCode" in body or "message" in body):
|
|
48
|
+
raise SalesforceError(
|
|
49
|
+
body.get("message", str(http_err)),
|
|
50
|
+
status_code=status_code,
|
|
51
|
+
error_code=body.get("errorCode"),
|
|
52
|
+
response_body=body,
|
|
53
|
+
) from http_err
|
|
54
|
+
|
|
55
|
+
raise SalesforceError(
|
|
56
|
+
str(http_err),
|
|
57
|
+
status_code=status_code,
|
|
58
|
+
response_body=body,
|
|
59
|
+
) from http_err
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_error_code(http_err: HTTPError) -> str | None:
|
|
63
|
+
response = http_err.response
|
|
64
|
+
if response is None:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
body = response.json()
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(body, list) and body and isinstance(body[0], dict):
|
|
71
|
+
return body[0].get("errorCode")
|
|
72
|
+
if isinstance(body, dict):
|
|
73
|
+
return body.get("errorCode")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_rate_limited(http_err: HTTPError) -> bool:
|
|
78
|
+
response = http_err.response
|
|
79
|
+
if response is not None and response.status_code == 429:
|
|
80
|
+
return True
|
|
81
|
+
return _extract_error_code(http_err) == "REQUEST_LIMIT_EXCEEDED"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SalesforceClient:
|
|
85
|
+
DEFAULT_API_VERSION = "v61.0"
|
|
86
|
+
_default_instance = None
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
auth_strategy: BaseAuthentication,
|
|
91
|
+
api_version: str = DEFAULT_API_VERSION,
|
|
92
|
+
*,
|
|
93
|
+
default: bool = False,
|
|
94
|
+
):
|
|
95
|
+
if not isinstance(auth_strategy, BaseAuthentication):
|
|
96
|
+
raise TypeError(
|
|
97
|
+
"auth_strategy must be an instance of a subclass of BaseAuthentication"
|
|
98
|
+
)
|
|
99
|
+
self.auth_strategy = auth_strategy
|
|
100
|
+
self.api_version = api_version
|
|
101
|
+
|
|
102
|
+
if default:
|
|
103
|
+
self.__class__._default_instance = self
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def set_default_instance(
|
|
107
|
+
cls,
|
|
108
|
+
auth_strategy: BaseAuthentication,
|
|
109
|
+
api_version: str = DEFAULT_API_VERSION,
|
|
110
|
+
):
|
|
111
|
+
"""
|
|
112
|
+
Sets the default SalesforceClient instance.
|
|
113
|
+
|
|
114
|
+
:param auth_strategy: An instance of a subclass of BaseAuthentication.
|
|
115
|
+
:param api_version: Salesforce REST API version (e.g. "v61.0").
|
|
116
|
+
"""
|
|
117
|
+
cls(auth_strategy, api_version=api_version, default=True)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def get_default_instance(cls):
|
|
121
|
+
"""
|
|
122
|
+
Retrieves the default SalesforceClient instance.
|
|
123
|
+
|
|
124
|
+
:return: The default SalesforceClient instance.
|
|
125
|
+
:raises ValueError: If the default instance has not been set.
|
|
126
|
+
"""
|
|
127
|
+
if cls._default_instance is None:
|
|
128
|
+
raise ValueError("Default instance not set")
|
|
129
|
+
return cls._default_instance
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_config(
|
|
133
|
+
cls,
|
|
134
|
+
alias: str = "default",
|
|
135
|
+
path: str = ".cloudy_config",
|
|
136
|
+
*,
|
|
137
|
+
default: bool = False,
|
|
138
|
+
api_version: str | None = None,
|
|
139
|
+
) -> "SalesforceClient":
|
|
140
|
+
config = load_cloudy_config(path)
|
|
141
|
+
alias_config = resolve_alias(config, alias)
|
|
142
|
+
|
|
143
|
+
effective_api_version = api_version
|
|
144
|
+
if effective_api_version is None:
|
|
145
|
+
effective_api_version = alias_config.get(
|
|
146
|
+
"api_version", cls.DEFAULT_API_VERSION
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
auth = build_auth_from_alias(
|
|
150
|
+
alias_config, api_version=effective_api_version
|
|
151
|
+
)
|
|
152
|
+
return cls(
|
|
153
|
+
auth,
|
|
154
|
+
api_version=effective_api_version,
|
|
155
|
+
default=default,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def get_session(self) -> requests.Session:
|
|
159
|
+
return self.auth_strategy.session
|
|
160
|
+
|
|
161
|
+
def get_instance_url(self) -> str:
|
|
162
|
+
return self.auth_strategy.instance_url
|
|
163
|
+
|
|
164
|
+
def request(
|
|
165
|
+
self,
|
|
166
|
+
method: str,
|
|
167
|
+
url: str,
|
|
168
|
+
body: dict | None = None,
|
|
169
|
+
params: dict | None = None,
|
|
170
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
171
|
+
if url.startswith("http://") or url.startswith("https://"):
|
|
172
|
+
request_url = url
|
|
173
|
+
else:
|
|
174
|
+
request_url = f"{self.get_instance_url()}{url}"
|
|
175
|
+
last_http_err: HTTPError | None = None
|
|
176
|
+
for attempt in range(len(_RATE_LIMIT_RETRY_DELAYS) + 1):
|
|
177
|
+
try:
|
|
178
|
+
response = self.get_session().request(
|
|
179
|
+
method, request_url, json=body, params=params, timeout=30
|
|
180
|
+
)
|
|
181
|
+
response.raise_for_status()
|
|
182
|
+
if not response.content:
|
|
183
|
+
return {}
|
|
184
|
+
return response.json()
|
|
185
|
+
|
|
186
|
+
except HTTPError as http_err:
|
|
187
|
+
last_http_err = http_err
|
|
188
|
+
if attempt < len(_RATE_LIMIT_RETRY_DELAYS) and _is_rate_limited(
|
|
189
|
+
http_err
|
|
190
|
+
):
|
|
191
|
+
time.sleep(_RATE_LIMIT_RETRY_DELAYS[attempt])
|
|
192
|
+
continue
|
|
193
|
+
logger.error(f"HTTP error occurred during query: {http_err}")
|
|
194
|
+
_raise_salesforce_error(http_err)
|
|
195
|
+
except Exception as err:
|
|
196
|
+
logger.error(f"Other error occurred during query: {err}")
|
|
197
|
+
raise
|
|
198
|
+
|
|
199
|
+
if last_http_err is not None:
|
|
200
|
+
_raise_salesforce_error(last_http_err)
|
|
201
|
+
raise RuntimeError("request failed without HTTP error")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .crud_operations import (
|
|
2
|
+
DeleteProps,
|
|
3
|
+
InsertProps,
|
|
4
|
+
UpdateProps,
|
|
5
|
+
UpsertProps,
|
|
6
|
+
collections,
|
|
7
|
+
delete,
|
|
8
|
+
insert,
|
|
9
|
+
update,
|
|
10
|
+
upsert,
|
|
11
|
+
)
|
|
12
|
+
from .return_functions import (
|
|
13
|
+
build_dml_results,
|
|
14
|
+
dml_results_only,
|
|
15
|
+
records_and_response,
|
|
16
|
+
success_failure,
|
|
17
|
+
)
|
|
18
|
+
from .types import DmlResult
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DeleteProps",
|
|
22
|
+
"DmlResult",
|
|
23
|
+
"InsertProps",
|
|
24
|
+
"UpdateProps",
|
|
25
|
+
"UpsertProps",
|
|
26
|
+
"build_dml_results",
|
|
27
|
+
"collections",
|
|
28
|
+
"delete",
|
|
29
|
+
"dml_results_only",
|
|
30
|
+
"insert",
|
|
31
|
+
"records_and_response",
|
|
32
|
+
"success_failure",
|
|
33
|
+
"update",
|
|
34
|
+
"upsert",
|
|
35
|
+
]
|