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.
- sf_toolkit-0.1.0/LICENSE.txt +21 -0
- sf_toolkit-0.1.0/PKG-INFO +87 -0
- sf_toolkit-0.1.0/README.md +67 -0
- sf_toolkit-0.1.0/pyproject.toml +34 -0
- sf_toolkit-0.1.0/src/sf_toolkit/__init__.py +15 -0
- sf_toolkit-0.1.0/src/sf_toolkit/_models.py +69 -0
- sf_toolkit-0.1.0/src/sf_toolkit/apimodels.py +220 -0
- sf_toolkit-0.1.0/src/sf_toolkit/async_utils.py +31 -0
- sf_toolkit-0.1.0/src/sf_toolkit/auth/__init__.py +33 -0
- sf_toolkit-0.1.0/src/sf_toolkit/auth/httpx.py +76 -0
- sf_toolkit-0.1.0/src/sf_toolkit/auth/login_cli.py +73 -0
- sf_toolkit-0.1.0/src/sf_toolkit/auth/login_lazy.py +27 -0
- sf_toolkit-0.1.0/src/sf_toolkit/auth/login_oauth.py +167 -0
- sf_toolkit-0.1.0/src/sf_toolkit/auth/login_soap.py +273 -0
- sf_toolkit-0.1.0/src/sf_toolkit/auth/types.py +26 -0
- sf_toolkit-0.1.0/src/sf_toolkit/client.py +223 -0
- sf_toolkit-0.1.0/src/sf_toolkit/data/fields.py +347 -0
- sf_toolkit-0.1.0/src/sf_toolkit/data/query_builder.py +180 -0
- sf_toolkit-0.1.0/src/sf_toolkit/data/sobject.py +1092 -0
- sf_toolkit-0.1.0/src/sf_toolkit/exceptions.py +337 -0
- sf_toolkit-0.1.0/src/sf_toolkit/formatting.py +96 -0
- sf_toolkit-0.1.0/src/sf_toolkit/interfaces.py +133 -0
- sf_toolkit-0.1.0/src/sf_toolkit/logger.py +9 -0
- sf_toolkit-0.1.0/src/sf_toolkit/metrics.py +51 -0
|
@@ -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
|