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.
@@ -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
+ ]