sf-toolkit 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 David Culbreth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.3
2
+ Name: sf_toolkit
3
+ Version: 0.1.0
4
+ Summary: A Salesforce API Adapter for Python
5
+ License: MIT
6
+ Author: David Culbreth
7
+ Author-email: david.culbreth.256@gmail.com
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
15
+ Requires-Dist: lxml (>=5.3.1,<6.0.0)
16
+ Requires-Dist: more-itertools (>=10.6.0,<11.0.0)
17
+ Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Salesforce Toolkit for Python
21
+
22
+ A modern, Pythonic interface to Salesforce APIs.
23
+
24
+ ## Features
25
+
26
+ - Clean, intuitive API design
27
+ - Both synchronous and asynchronous client support
28
+ - Simple SObject modeling using Python classes
29
+ - Powerful query builder for SOQL queries
30
+ - Efficient batch operations
31
+ - Automatic session management and token refresh
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install sf-toolkit
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ```python
42
+ from sf_toolkit import SalesforceClient, SObject, cli_login
43
+ from sf_toolkit.data.fields import IdField, TextField
44
+
45
+ # Define a Salesforce object model
46
+ class Account(SObject, api_name="Account"):
47
+ Id = IdField()
48
+ Name = TextField()
49
+ Industry = TextField()
50
+ Description = TextField()
51
+
52
+ # Connect to Salesforce using the CLI authentication
53
+ with SalesforceClient(login=cli_login()) as sf:
54
+ # Create a new account
55
+ account = Account(Name="Acme Corp", Industry="Technology")
56
+ account.save()
57
+
58
+ # Query accounts
59
+ query = SoqlSelect(Account)
60
+ results = query.query()
61
+
62
+ for acc in results.records:
63
+ print(f"{acc.Name} ({acc.Industry})")
64
+ ```
65
+
66
+ ## Documentation
67
+
68
+ For full documentation, visit [docs.example.com](https://docs.example.com).
69
+ ### Building the documentation
70
+
71
+ You can build the documentation locally with:
72
+
73
+ ```bash
74
+ # One-time build
75
+ python -m sphinx -b html docs/source docs/build/html
76
+
77
+ # Or with auto-reload during development
78
+ sphinx-autobuild docs/source docs/build/html
79
+ ```
80
+
81
+ The documentation is automatically built from docstrings in the code, so make sure to write
82
+ comprehensive docstrings for all public classes and methods.
83
+
84
+ ## License
85
+
86
+ MIT
87
+
@@ -0,0 +1,67 @@
1
+ # Salesforce Toolkit for Python
2
+
3
+ A modern, Pythonic interface to Salesforce APIs.
4
+
5
+ ## Features
6
+
7
+ - Clean, intuitive API design
8
+ - Both synchronous and asynchronous client support
9
+ - Simple SObject modeling using Python classes
10
+ - Powerful query builder for SOQL queries
11
+ - Efficient batch operations
12
+ - Automatic session management and token refresh
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install sf-toolkit
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```python
23
+ from sf_toolkit import SalesforceClient, SObject, cli_login
24
+ from sf_toolkit.data.fields import IdField, TextField
25
+
26
+ # Define a Salesforce object model
27
+ class Account(SObject, api_name="Account"):
28
+ Id = IdField()
29
+ Name = TextField()
30
+ Industry = TextField()
31
+ Description = TextField()
32
+
33
+ # Connect to Salesforce using the CLI authentication
34
+ with SalesforceClient(login=cli_login()) as sf:
35
+ # Create a new account
36
+ account = Account(Name="Acme Corp", Industry="Technology")
37
+ account.save()
38
+
39
+ # Query accounts
40
+ query = SoqlSelect(Account)
41
+ results = query.query()
42
+
43
+ for acc in results.records:
44
+ print(f"{acc.Name} ({acc.Industry})")
45
+ ```
46
+
47
+ ## Documentation
48
+
49
+ For full documentation, visit [docs.example.com](https://docs.example.com).
50
+ ### Building the documentation
51
+
52
+ You can build the documentation locally with:
53
+
54
+ ```bash
55
+ # One-time build
56
+ python -m sphinx -b html docs/source docs/build/html
57
+
58
+ # Or with auto-reload during development
59
+ sphinx-autobuild docs/source docs/build/html
60
+ ```
61
+
62
+ The documentation is automatically built from docstrings in the code, so make sure to write
63
+ comprehensive docstrings for all public classes and methods.
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,34 @@
1
+ [tool.poetry]
2
+ name = "sf_toolkit"
3
+ version = "0.1.0"
4
+ description = "A Salesforce API Adapter for Python"
5
+ authors = ["David Culbreth <david.culbreth.256@gmail.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.11"
11
+ httpx = "^0.28.1"
12
+ lxml = "^5.3.1"
13
+ more-itertools = "^10.6.0"
14
+ pyjwt = "^2.10.1"
15
+
16
+
17
+ [tool.poetry.group.dev.dependencies]
18
+ pytest = "^8.3.5"
19
+ pytest-asyncio = "^0.26.0"
20
+ ruff = "^0.11.2"
21
+ ipykernel = "^6.29.5"
22
+ pytest-cov = "^6.0.0"
23
+ pytest-mock = "^3.14.0"
24
+ pytest-integration-mark = "^0.2.0"
25
+
26
+
27
+ [tool.poetry.group.docs.dependencies]
28
+ sphinx = "^8.2.3"
29
+ sphinx-rtd-theme = "^3.0.2"
30
+ sphinx-autobuild = "^2021.3.14"
31
+
32
+ [build-system]
33
+ requires = ["poetry-core"]
34
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,15 @@
1
+ from .client import SalesforceClient, AsyncSalesforceClient
2
+ from .auth import SalesforceToken, SalesforceAuth, lazy_login, cli_login
3
+ from .data.sobject import SObject
4
+ from .data.query_builder import SoqlSelect
5
+
6
+ __all__ = [
7
+ "SalesforceClient",
8
+ "AsyncSalesforceClient",
9
+ "SalesforceAuth",
10
+ "SalesforceToken",
11
+ "SObject",
12
+ "SoqlSelect",
13
+ "lazy_login",
14
+ "cli_login",
15
+ ]
@@ -0,0 +1,69 @@
1
+ from typing import TypedDict, Generic, TypeVar, NamedTuple
2
+
3
+
4
+ class SObjectAttributes(NamedTuple):
5
+ type: str
6
+ connection: str
7
+ id_field: str
8
+ tooling: bool
9
+
10
+
11
+ class SObjectDictAttrs(TypedDict):
12
+ type: str
13
+ url: str
14
+
15
+
16
+ class SObjectDict(TypedDict, total=False):
17
+ attributes: SObjectDictAttrs
18
+
19
+
20
+ class SObjectSaveError(NamedTuple):
21
+ statusCode: str
22
+ message: str
23
+ fields: list[str]
24
+
25
+ def __str__(self):
26
+ return f"({self.statusCode}) {self.message} ({', '.join(self.fields)})"
27
+
28
+
29
+ class SObjectSaveResult:
30
+ id: str
31
+ success: bool
32
+ errors: list[SObjectSaveError]
33
+ created: bool | None
34
+
35
+ def __init__(
36
+ self,
37
+ id: str,
38
+ success: bool,
39
+ errors: list[SObjectSaveError | dict],
40
+ created: bool | None = None,
41
+ ):
42
+ self.id = id
43
+ self.success = success
44
+ self.errors = [
45
+ error if isinstance(error, SObjectSaveError) else SObjectSaveError(**error)
46
+ for error in errors
47
+ ]
48
+ self.created = created
49
+
50
+ def __repr__(self) -> str:
51
+ return f"<{type(self).__name__} id:{self.id} success:{self.success} errors:[{', '.join(map(str, self.errors))}]>"
52
+
53
+ def __str__(self):
54
+ message = f"Save Result for record {self.id} | "
55
+ message += "SUCCESS" if self.success else "FAILURE"
56
+ if self.errors:
57
+ message += "\n errors:[\n " + "\n ".join(map(str, self.errors)) + "\n]"
58
+
59
+ return message
60
+
61
+
62
+ SObjectRecordJSON = TypeVar("SObjectRecordJSON", bound=SObjectDict)
63
+
64
+
65
+ class QueryResultJSON(TypedDict, Generic[SObjectRecordJSON]):
66
+ totalSize: int
67
+ done: bool
68
+ nextRecordsUrl: str
69
+ records: list[SObjectRecordJSON]
@@ -0,0 +1,220 @@
1
+ from typing import TypeVar
2
+
3
+
4
+ _T_ApiVer = TypeVar("_T_ApiVer", bound="ApiVersion")
5
+
6
+
7
+ class ApiVersion:
8
+ """
9
+ Data structure representing a Salesforce API version.
10
+ https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_versions.htm
11
+ """
12
+
13
+ version: float
14
+ label: str
15
+ url: str
16
+
17
+ def __init__(self, version: float | str, label: str, url: str):
18
+ """
19
+ Initialize an ApiVersion object.
20
+
21
+ Args:
22
+ version: The API version number as a float
23
+ label: The display label for the API version
24
+ url: The URL for accessing this API version
25
+ """
26
+ self.version = float(version)
27
+ self.label = label
28
+ self.url = url
29
+
30
+ @classmethod
31
+ def lazy_build(cls, value) -> "ApiVersion":
32
+ if isinstance(value, cls):
33
+ return value
34
+ elif isinstance(value, str):
35
+ if value.startswith("/services/data/v"):
36
+ version_number = float(value.removeprefix("/services/data/v"))
37
+ return cls(version_number, f"{version_number:.01f}", value)
38
+ else:
39
+ # attempt to isolate version number from any other characters
40
+ value = "".join(c for c in value if c.isdigit() or c == ".")
41
+ version_number = float(value)
42
+ return cls(
43
+ version_number,
44
+ f"{version_number:.01f}",
45
+ f"/services/data/v{version_number:.01f}",
46
+ )
47
+
48
+ elif isinstance(value, float):
49
+ return cls(value, f"{value:.01f}", f"/services/data/v{value:.01f}")
50
+
51
+ elif isinstance(value, int):
52
+ value = float(value)
53
+ return cls(value, f"{value:.01f}", f"/services/data/v{value:.01f}")
54
+
55
+ elif isinstance(value, dict):
56
+ return cls(**value)
57
+
58
+ raise TypeError("Unable to build an ApiVersion from value %s", repr(value))
59
+
60
+
61
+ raise TypeError("Unable to build an ApiVersion from value %s", repr(value))
62
+
63
+ def __repr__(self) -> str:
64
+ return f"ApiVersion(version={self.version}, label='{self.label}')"
65
+
66
+ def __str__(self) -> str:
67
+ return f"Salesforce API Version {self.label} ({self.version:.01f})"
68
+
69
+ def __float__(self) -> float:
70
+ return self.version
71
+
72
+ def __eq__(self, other) -> bool:
73
+ if isinstance(other, ApiVersion):
74
+ return self.version == other.version and self.url == other.url
75
+ elif isinstance(other, (int, float)):
76
+ return self.version == float(other)
77
+ return False
78
+
79
+ def __hash__(self) -> int:
80
+ return hash(self.version)
81
+
82
+
83
+ class OrgLimit:
84
+ """
85
+ Data structure representing a Salesforce Org Limit.
86
+ https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_limits.htm
87
+ """
88
+
89
+ def __init__(self, name: str, max_value: int, current_value: int):
90
+ """
91
+ Initialize an OrgLimit object.
92
+
93
+ Args:
94
+ name: The name of the limit
95
+ max_value: The maximum allowed value for this limit
96
+ current_value: The current consumption value for this limit
97
+ """
98
+ self.name = name
99
+ self.max_value = max_value
100
+ self.current_value = current_value
101
+
102
+ def __repr__(self) -> str:
103
+ return f"OrgLimit(name='{self.name}', current_value={self.current_value}, max_value={self.max_value})"
104
+
105
+ def remaining(self) -> int:
106
+ """
107
+ Calculate the remaining capacity for this limit.
108
+
109
+ Returns:
110
+ The difference between max_value and current_value
111
+ """
112
+ return self.max_value - self.current_value
113
+
114
+ def usage_percentage(self) -> float:
115
+ """
116
+ Calculate the percentage of the limit that has been used.
117
+
118
+ Returns:
119
+ The percentage of the limit used as a float between 0 and 100
120
+ """
121
+ if self.max_value == 0:
122
+ return 0.0
123
+ return (self.current_value / self.max_value) * 100
124
+
125
+ def is_critical(self, threshold: float = 90.0) -> bool:
126
+ """
127
+ Determine if the limit usage exceeds a critical threshold.
128
+
129
+ Args:
130
+ threshold: The percentage threshold to consider critical (default: 90%)
131
+
132
+ Returns:
133
+ True if usage percentage exceeds the threshold, False otherwise
134
+ """
135
+ return self.usage_percentage() >= threshold
136
+
137
+
138
+ class UserInfo:
139
+ """
140
+ Data structure representing user information returned from the Salesforce OAuth2 userinfo endpoint.
141
+ https://help.salesforce.com/s/articleView?id=sf.remoteaccess_using_userinfo_endpoint.htm
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ user_id: str,
147
+ name: str,
148
+ email: str,
149
+ organization_id: str,
150
+ sub: str,
151
+ email_verified: bool,
152
+ given_name: str,
153
+ family_name: str,
154
+ zoneinfo: str,
155
+ photos: dict[str, str],
156
+ profile: str,
157
+ picture: str,
158
+ address: dict,
159
+ urls: dict[str, str],
160
+ active: bool,
161
+ user_type: str,
162
+ language: str,
163
+ locale: str,
164
+ utcOffset: int,
165
+ updated_at: str,
166
+ preferred_username: str,
167
+ **kwargs,
168
+ ):
169
+ """
170
+ Initialize a UserInfo object.
171
+
172
+ Args:
173
+ user_id: The user's Salesforce ID
174
+ name: The user's full name
175
+ email: The user's email address
176
+ organization_id: The organization's Salesforce ID
177
+ sub: Subject identifier
178
+ email_verified: Whether the email has been verified
179
+ given_name: The user's first name
180
+ family_name: The user's last name
181
+ zoneinfo: The user's timezone (e.g., "America/Los_Angeles")
182
+ photos: Dictionary of profile photos (picture, thumbnail)
183
+ profile: URL to the user's profile
184
+ picture: URL to the user's profile picture
185
+ address: Dictionary containing address information
186
+ urls: Dictionary of various API endpoints for this user
187
+ active: Whether the user is active
188
+ user_type: The type of user (e.g., "STANDARD")
189
+ language: The user's language preference
190
+ locale: The user's locale setting
191
+ utcOffset: The user's UTC offset in milliseconds
192
+ updated_at: When the user information was last updated
193
+ preferred_username: The user's preferred username (typically email)
194
+ **kwargs: Additional attributes from the response
195
+ """
196
+ self.user_id = user_id
197
+ self.name = name
198
+ self.email = email
199
+ self.organization_id = organization_id
200
+ self.sub = sub
201
+ self.email_verified = email_verified
202
+ self.given_name = given_name
203
+ self.family_name = family_name
204
+ self.zoneinfo = zoneinfo
205
+ self.photos = photos or {}
206
+ self.profile = profile
207
+ self.picture = picture
208
+ self.address = address or {}
209
+ self.urls = urls or {}
210
+ self.active = active
211
+ self.user_type = user_type
212
+ self.language = language
213
+ self.locale = locale
214
+ self.utcOffset = utcOffset
215
+ self.updated_at = updated_at
216
+ self.preferred_username = preferred_username
217
+ self.additional_info = kwargs
218
+
219
+ def __repr__(self) -> str:
220
+ return f"UserInfo(name='{self.name}', user_id='{self.user_id}', organization_id='{self.organization_id}')"
@@ -0,0 +1,31 @@
1
+ from asyncio import BoundedSemaphore, gather
2
+ from collections.abc import Iterable
3
+ from typing import Callable, TypeVar, Awaitable
4
+ from types import CoroutineType
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ async def run_concurrently(
10
+ limit: int,
11
+ coroutines: Iterable[Awaitable[T]],
12
+ task_callback: Callable[[T], Awaitable[None] | None] | None = None,
13
+ ) -> list[T]:
14
+ """Runs the provided coroutines with maxumum `n` concurrently running."""
15
+ semaphore = BoundedSemaphore(limit)
16
+
17
+ async def bounded_task(task: Awaitable[T]):
18
+ async with semaphore:
19
+ result = await task
20
+ if task_callback:
21
+ callback_result = task_callback(result)
22
+ if isinstance(callback_result, CoroutineType):
23
+ await callback_result
24
+ return result
25
+
26
+ # Wrap all coroutines in the semaphore-controlled task
27
+ tasks = [bounded_task(coro) for coro in coroutines]
28
+
29
+ # Run and wait for all tasks to complete
30
+ results: list[T] = await gather(*tasks)
31
+ return results
@@ -0,0 +1,33 @@
1
+ from .httpx import SalesforceAuth
2
+ from .types import SalesforceLogin, SalesforceToken, TokenRefreshCallback
3
+ from .login_lazy import lazy_login
4
+ from .login_cli import cli_login
5
+ from .login_soap import (
6
+ ip_filtering_non_service_login,
7
+ ip_filtering_org_login,
8
+ security_token_login,
9
+ lazy_soap_login,
10
+ )
11
+ from .login_oauth import (
12
+ lazy_oauth_login,
13
+ password_login,
14
+ public_key_auth_login,
15
+ client_credentials_flow_login,
16
+ )
17
+
18
+ __all__ = [
19
+ "SalesforceAuth",
20
+ "SalesforceLogin",
21
+ "SalesforceToken",
22
+ "TokenRefreshCallback",
23
+ "lazy_login",
24
+ "cli_login",
25
+ "ip_filtering_non_service_login",
26
+ "ip_filtering_org_login",
27
+ "security_token_login",
28
+ "lazy_soap_login",
29
+ "lazy_oauth_login",
30
+ "password_login",
31
+ "public_key_auth_login",
32
+ "client_credentials_flow_login",
33
+ ]
@@ -0,0 +1,76 @@
1
+ import typing
2
+
3
+ import httpx
4
+
5
+ from ..logger import getLogger
6
+ from .types import SalesforceLogin, SalesforceToken, TokenRefreshCallback
7
+
8
+ LOGGER = getLogger("auth")
9
+
10
+
11
+ class SalesforceAuth(httpx.Auth):
12
+ login: SalesforceLogin | None
13
+ callback: TokenRefreshCallback | None
14
+ token: SalesforceToken | None
15
+
16
+ def __init__(
17
+ self,
18
+ login: SalesforceLogin | None = None,
19
+ session_token: SalesforceToken | None = None,
20
+ callback: TokenRefreshCallback | None = None,
21
+ ):
22
+ self.login = login
23
+ self.token = session_token
24
+ self.callback = callback
25
+
26
+ def auth_flow(
27
+ self, request: httpx.Request
28
+ ) -> typing.Generator[httpx.Request, httpx.Response, None]:
29
+ if self.token is None or request.url.is_relative_url:
30
+ assert self.login is not None, "No login method provided"
31
+ try:
32
+ login_flow = self.login()
33
+ login_request = next(login_flow)
34
+ while True:
35
+ if login_request is not None:
36
+ login_response = yield login_request
37
+ login_request = login_flow.send(login_response)
38
+ else:
39
+ login_request = next(login_flow)
40
+
41
+ except StopIteration as login_result:
42
+ new_token: SalesforceToken = login_result.value
43
+ self.token = SalesforceToken(*new_token)
44
+ if self.callback is not None:
45
+ self.callback(new_token)
46
+ assert self.token is not None, "Failed to perform initial login"
47
+
48
+ if request.url.is_relative_url:
49
+ absolute_url = self.token.instance.raw_path + request.url.raw_path.lstrip(
50
+ b"/"
51
+ )
52
+ request.url = self.token.instance.copy_with(raw_path=absolute_url)
53
+ request._prepare({**request.headers})
54
+
55
+ request.headers["Authorization"] = f"Bearer {self.token.token}"
56
+ response = yield request
57
+
58
+ if (
59
+ response.status_code == 401
60
+ and self.login
61
+ and response.json()[0]["errorDetails"] == "INVALID_SESSION_ID"
62
+ ):
63
+ try:
64
+ for login_request in (login_flow := self.login()):
65
+ if login_request is not None:
66
+ login_response = yield login_request
67
+ login_flow.send(login_response)
68
+
69
+ except StopIteration as login_result:
70
+ new_token: SalesforceToken = login_result.value
71
+ self.token = new_token
72
+ if self.callback is not None:
73
+ self.callback(new_token)
74
+
75
+ request.headers["Authorization"] = f"Bearer {self.token.token}"
76
+ response = yield request