salesforce-agent 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.
- salesforce_agent/__init__.py +69 -0
- salesforce_agent/__main__.py +4 -0
- salesforce_agent/agent_server.py +77 -0
- salesforce_agent/api/__init__.py +17 -0
- salesforce_agent/api/api_client_admin.py +58 -0
- salesforce_agent/api/api_client_base.py +119 -0
- salesforce_agent/api/api_client_bulk.py +145 -0
- salesforce_agent/api/api_client_describe.py +40 -0
- salesforce_agent/api/api_client_query.py +68 -0
- salesforce_agent/api/api_client_records.py +164 -0
- salesforce_agent/api_client.py +47 -0
- salesforce_agent/auth.py +321 -0
- salesforce_agent/main_agent.json +14 -0
- salesforce_agent/mcp/__init__.py +19 -0
- salesforce_agent/mcp/mcp_salesforce.py +259 -0
- salesforce_agent/mcp_config.json +3 -0
- salesforce_agent/mcp_server.py +75 -0
- salesforce_agent/salesforce_input_models.py +161 -0
- salesforce_agent/salesforce_response_models.py +150 -0
- salesforce_agent-0.2.0.dist-info/METADATA +215 -0
- salesforce_agent-0.2.0.dist-info/RECORD +25 -0
- salesforce_agent-0.2.0.dist-info/WHEEL +5 -0
- salesforce_agent-0.2.0.dist-info/entry_points.txt +3 -0
- salesforce_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- salesforce_agent-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""CONCEPT:ECO-4.0 Unified ecosystem initialization dynamic check."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
__all__: list[str] = []
|
|
9
|
+
|
|
10
|
+
CORE_MODULES = [
|
|
11
|
+
"salesforce_agent.salesforce_response_models",
|
|
12
|
+
"salesforce_agent.salesforce_input_models",
|
|
13
|
+
"salesforce_agent.auth",
|
|
14
|
+
"salesforce_agent.api_client",
|
|
15
|
+
]
|
|
16
|
+
OPTIONAL_MODULES = {
|
|
17
|
+
"salesforce_agent.agent_server": "agent",
|
|
18
|
+
"salesforce_agent.mcp_server": "mcp",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _expose_members(module):
|
|
23
|
+
for name, obj in inspect.getmembers(module):
|
|
24
|
+
if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
|
|
25
|
+
"_"
|
|
26
|
+
):
|
|
27
|
+
globals()[name] = obj
|
|
28
|
+
if name not in __all__:
|
|
29
|
+
__all__.append(name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
for module_name in CORE_MODULES:
|
|
33
|
+
module = importlib.import_module(module_name)
|
|
34
|
+
_expose_members(module)
|
|
35
|
+
|
|
36
|
+
_loaded_optional_modules: dict[str, Any] = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _import_module_safely(module_name: str):
|
|
40
|
+
try:
|
|
41
|
+
return importlib.import_module(module_name)
|
|
42
|
+
except ImportError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def __getattr__(name: str) -> Any:
|
|
47
|
+
if name == "_MCP_AVAILABLE":
|
|
48
|
+
mcp_key = next((k for k in OPTIONAL_MODULES if "mcp_server" in k), None)
|
|
49
|
+
return _import_module_safely(mcp_key) is not None if mcp_key else False
|
|
50
|
+
if name == "_AGENT_AVAILABLE":
|
|
51
|
+
agent_key = next((k for k in OPTIONAL_MODULES if "agent_server" in k), None)
|
|
52
|
+
return _import_module_safely(agent_key) is not None if agent_key else False
|
|
53
|
+
|
|
54
|
+
for module_name in OPTIONAL_MODULES:
|
|
55
|
+
if module_name not in _loaded_optional_modules:
|
|
56
|
+
module = _import_module_safely(module_name)
|
|
57
|
+
if module is not None:
|
|
58
|
+
_loaded_optional_modules[module_name] = module
|
|
59
|
+
_expose_members(module)
|
|
60
|
+
|
|
61
|
+
module = _loaded_optional_modules.get(module_name)
|
|
62
|
+
if module is not None and hasattr(module, name):
|
|
63
|
+
return getattr(module, name)
|
|
64
|
+
|
|
65
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def __dir__() -> list[str]:
|
|
69
|
+
return sorted(list(globals().keys()) + __all__)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Pydantic AI A2A agent server for the Salesforce connector."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.INFO,
|
|
12
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
13
|
+
)
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DEFAULT_AGENT_NAME = None
|
|
17
|
+
DEFAULT_AGENT_DESCRIPTION = None
|
|
18
|
+
DEFAULT_AGENT_SYSTEM_PROMPT = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def agent_server():
|
|
22
|
+
"""Start graph-based Pydantic AI agent server."""
|
|
23
|
+
from agent_utilities import (
|
|
24
|
+
build_system_prompt_from_workspace,
|
|
25
|
+
create_agent_parser,
|
|
26
|
+
create_agent_server,
|
|
27
|
+
initialize_workspace,
|
|
28
|
+
load_identity,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
global DEFAULT_AGENT_NAME, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_SYSTEM_PROMPT
|
|
32
|
+
initialize_workspace()
|
|
33
|
+
meta = load_identity()
|
|
34
|
+
DEFAULT_AGENT_NAME = os.getenv(
|
|
35
|
+
"DEFAULT_AGENT_NAME", meta.get("name", "Salesforce Agent")
|
|
36
|
+
)
|
|
37
|
+
DEFAULT_AGENT_DESCRIPTION = os.getenv(
|
|
38
|
+
"AGENT_DESCRIPTION",
|
|
39
|
+
meta.get("description", "AI agent for Salesforce CRM operations."),
|
|
40
|
+
)
|
|
41
|
+
DEFAULT_AGENT_SYSTEM_PROMPT = os.getenv(
|
|
42
|
+
"AGENT_SYSTEM_PROMPT",
|
|
43
|
+
meta.get("content") or build_system_prompt_from_workspace(),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
warnings.filterwarnings("ignore", message=".*urllib3.*")
|
|
47
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning, module="fastmcp")
|
|
48
|
+
|
|
49
|
+
print(f"{DEFAULT_AGENT_NAME} v{__version__}", file=sys.stderr)
|
|
50
|
+
parser = create_agent_parser()
|
|
51
|
+
args = parser.parse_args()
|
|
52
|
+
|
|
53
|
+
create_agent_server(
|
|
54
|
+
mcp_url=args.mcp_url,
|
|
55
|
+
mcp_config=args.mcp_config or "mcp_config.json",
|
|
56
|
+
host=args.host,
|
|
57
|
+
port=args.port,
|
|
58
|
+
provider=args.provider,
|
|
59
|
+
model_id=args.model_id,
|
|
60
|
+
router_model=args.model_id,
|
|
61
|
+
agent_model=args.model_id,
|
|
62
|
+
base_url=args.base_url,
|
|
63
|
+
api_key=args.api_key,
|
|
64
|
+
custom_skills_directory=args.custom_skills_directory,
|
|
65
|
+
enable_web_ui=args.web,
|
|
66
|
+
enable_otel=args.otel,
|
|
67
|
+
otel_endpoint=args.otel_endpoint,
|
|
68
|
+
otel_headers=args.otel_headers,
|
|
69
|
+
otel_public_key=args.otel_public_key,
|
|
70
|
+
otel_secret_key=args.otel_secret_key,
|
|
71
|
+
otel_protocol=args.otel_protocol,
|
|
72
|
+
debug=args.debug,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
agent_server()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Salesforce REST API resource clients (owned thin httpx wrappers)."""
|
|
2
|
+
|
|
3
|
+
from salesforce_agent.api.api_client_admin import AdminClient
|
|
4
|
+
from salesforce_agent.api.api_client_base import ApiClientBase
|
|
5
|
+
from salesforce_agent.api.api_client_bulk import BulkClient
|
|
6
|
+
from salesforce_agent.api.api_client_describe import DescribeClient
|
|
7
|
+
from salesforce_agent.api.api_client_query import QueryClient
|
|
8
|
+
from salesforce_agent.api.api_client_records import RecordsClient
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AdminClient",
|
|
12
|
+
"ApiClientBase",
|
|
13
|
+
"BulkClient",
|
|
14
|
+
"DescribeClient",
|
|
15
|
+
"QueryClient",
|
|
16
|
+
"RecordsClient",
|
|
17
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CONCEPT:SFDC-1.2 Org administration: identity, org info, and analytics.
|
|
2
|
+
|
|
3
|
+
Resources:
|
|
4
|
+
- UserInfo (OIDC): https://help.salesforce.com/s/articleView?id=sf.remoteaccess_using_userinfo_endpoint.htm
|
|
5
|
+
- Reports REST: https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_intro.htm
|
|
6
|
+
|
|
7
|
+
Flows (list/run via the Actions or Tooling APIs) are intentionally OUT of
|
|
8
|
+
scope for v1 of this connector — running flows mutates org state through an
|
|
9
|
+
API surface with weaker guardrails; revisit behind ``allow_destructive``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from salesforce_agent.api.api_client_base import ApiClientBase
|
|
15
|
+
|
|
16
|
+
ORG_INFO_SOQL = (
|
|
17
|
+
"SELECT Id, Name, OrganizationType, IsSandbox, InstanceName, "
|
|
18
|
+
"LanguageLocaleKey, TrialExpirationDate FROM Organization"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AdminClient:
|
|
23
|
+
"""Current user/org identity plus the synchronous Reports REST surface."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, base: ApiClientBase):
|
|
26
|
+
self._client = base
|
|
27
|
+
|
|
28
|
+
def user_info(self) -> dict[str, Any]:
|
|
29
|
+
"""Identity of the integration user (``/services/oauth2/userinfo``)."""
|
|
30
|
+
return self._client.request("GET", "/services/oauth2/userinfo")
|
|
31
|
+
|
|
32
|
+
def org_info(self) -> dict[str, Any]:
|
|
33
|
+
"""Organization record (name, type, sandbox flag, instance)."""
|
|
34
|
+
return self._client.request(
|
|
35
|
+
"GET",
|
|
36
|
+
f"{self._client.data_base}/query",
|
|
37
|
+
params={"q": ORG_INFO_SOQL},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def list_reports(self) -> Any:
|
|
41
|
+
"""Recently viewed reports (``/analytics/reports`` list resource)."""
|
|
42
|
+
return self._client.request(
|
|
43
|
+
"GET", f"{self._client.data_base}/analytics/reports"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def run_report(
|
|
47
|
+
self, report_id: str, include_details: bool = True
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""Run a report synchronously.
|
|
50
|
+
|
|
51
|
+
Salesforce caps synchronous report results at 2,000 detail rows —
|
|
52
|
+
results beyond the cap are dropped by the platform, not paged.
|
|
53
|
+
"""
|
|
54
|
+
return self._client.request(
|
|
55
|
+
"GET",
|
|
56
|
+
f"{self._client.data_base}/analytics/reports/{report_id}",
|
|
57
|
+
params={"includeDetails": str(include_details).lower()},
|
|
58
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""CONCEPT:SFDC-1.0 Shared httpx base client for the Salesforce REST API.
|
|
2
|
+
|
|
3
|
+
Owned thin client (no ``simple-salesforce``): a single httpx.Client that
|
|
4
|
+
attaches the cached bearer token, maps failures to typed errors
|
|
5
|
+
(:mod:`salesforce_agent.salesforce_response_models`), retries exactly once on 401 after
|
|
6
|
+
invalidating the token cache, and redacts secrets from anything it raises.
|
|
7
|
+
|
|
8
|
+
REST API reference:
|
|
9
|
+
https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/intro_rest.htm
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from salesforce_agent.auth import SalesforceAuth
|
|
17
|
+
from salesforce_agent.salesforce_response_models import map_response_error
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApiClientBase:
|
|
21
|
+
"""Token-aware request runner shared by every resource client."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
auth: SalesforceAuth,
|
|
26
|
+
transport: httpx.BaseTransport | None = None,
|
|
27
|
+
):
|
|
28
|
+
self.auth = auth
|
|
29
|
+
self._http = httpx.Client(
|
|
30
|
+
timeout=auth.config.timeout,
|
|
31
|
+
verify=auth.config.verify,
|
|
32
|
+
transport=transport,
|
|
33
|
+
follow_redirects=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def data_base(self) -> str:
|
|
38
|
+
"""Versioned REST root, e.g. ``/services/data/v62.0``."""
|
|
39
|
+
return f"/services/data/{self.auth.config.api_version}"
|
|
40
|
+
|
|
41
|
+
def request_raw(
|
|
42
|
+
self,
|
|
43
|
+
method: str,
|
|
44
|
+
endpoint: str,
|
|
45
|
+
*,
|
|
46
|
+
params: dict[str, Any] | None = None,
|
|
47
|
+
json: Any | None = None,
|
|
48
|
+
content: str | bytes | None = None,
|
|
49
|
+
headers: dict[str, str] | None = None,
|
|
50
|
+
_retried: bool = False,
|
|
51
|
+
) -> httpx.Response:
|
|
52
|
+
"""Run one authenticated request and return the raw response.
|
|
53
|
+
|
|
54
|
+
``endpoint`` is joined onto the org's instance URL unless it is
|
|
55
|
+
already absolute. A 401 invalidates the token cache and retries once
|
|
56
|
+
(refresh-on-401); any remaining HTTP >= 400 raises a typed
|
|
57
|
+
:class:`~salesforce_agent.salesforce_response_models.SalesforceError` with secrets
|
|
58
|
+
redacted from the message.
|
|
59
|
+
"""
|
|
60
|
+
token, instance_url = self.auth.token()
|
|
61
|
+
url = endpoint if endpoint.startswith("http") else f"{instance_url}{endpoint}"
|
|
62
|
+
request_headers = {"Authorization": f"Bearer {token}"}
|
|
63
|
+
if headers:
|
|
64
|
+
request_headers.update(headers)
|
|
65
|
+
|
|
66
|
+
response = self._http.request(
|
|
67
|
+
method=method,
|
|
68
|
+
url=url,
|
|
69
|
+
params=params,
|
|
70
|
+
json=json,
|
|
71
|
+
content=content,
|
|
72
|
+
headers=request_headers,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if response.status_code == 401 and not _retried and self.auth.can_refresh:
|
|
76
|
+
self.auth.invalidate()
|
|
77
|
+
return self.request_raw(
|
|
78
|
+
method,
|
|
79
|
+
endpoint,
|
|
80
|
+
params=params,
|
|
81
|
+
json=json,
|
|
82
|
+
content=content,
|
|
83
|
+
headers=headers,
|
|
84
|
+
_retried=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if response.status_code >= 400:
|
|
88
|
+
raise map_response_error(
|
|
89
|
+
response.status_code, response.text, redact=self.auth.redact
|
|
90
|
+
)
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
def request(
|
|
94
|
+
self,
|
|
95
|
+
method: str,
|
|
96
|
+
endpoint: str,
|
|
97
|
+
*,
|
|
98
|
+
params: dict[str, Any] | None = None,
|
|
99
|
+
json: Any | None = None,
|
|
100
|
+
content: str | bytes | None = None,
|
|
101
|
+
headers: dict[str, str] | None = None,
|
|
102
|
+
) -> Any:
|
|
103
|
+
"""Run a request and return parsed JSON (or a success/text envelope)."""
|
|
104
|
+
response = self.request_raw(
|
|
105
|
+
method,
|
|
106
|
+
endpoint,
|
|
107
|
+
params=params,
|
|
108
|
+
json=json,
|
|
109
|
+
content=content,
|
|
110
|
+
headers=headers,
|
|
111
|
+
)
|
|
112
|
+
if response.status_code == 204 or not response.text.strip():
|
|
113
|
+
return {"success": True, "status_code": response.status_code}
|
|
114
|
+
if "json" in response.headers.get("content-type", ""):
|
|
115
|
+
return response.json()
|
|
116
|
+
return {"status_code": response.status_code, "text": response.text}
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
self._http.close()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""CONCEPT:SFDC-1.4 Bulk API 2.0 ingest job client (CSV in, CSV results out).
|
|
2
|
+
|
|
3
|
+
Resources (Bulk API 2.0 developer guide):
|
|
4
|
+
- Create job: https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/create_job.htm
|
|
5
|
+
- Upload data: https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/upload_job_data.htm
|
|
6
|
+
- Close/abort: https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/close_job.htm
|
|
7
|
+
- Job info: https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/get_job_info.htm
|
|
8
|
+
- Results: https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/get_job_successful_results.htm
|
|
9
|
+
|
|
10
|
+
CONCEPT:SFDC-1.3 — ``delete``/``hardDelete`` ingest jobs are gated by
|
|
11
|
+
``allow_destructive``; result downloads are size-capped
|
|
12
|
+
(``SALESFORCE_BULK_RESULTS_MAX_BYTES``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from salesforce_agent.api.api_client_base import ApiClientBase
|
|
18
|
+
from salesforce_agent.salesforce_response_models import (
|
|
19
|
+
DestructiveOperationBlockedError,
|
|
20
|
+
SalesforceBadRequestError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
INGEST_OPERATIONS = {"insert", "update", "upsert", "delete", "hardDelete"}
|
|
24
|
+
DESTRUCTIVE_OPERATIONS = {"delete", "hardDelete"}
|
|
25
|
+
RESULT_KINDS = {
|
|
26
|
+
"successful": "successfulResults",
|
|
27
|
+
"failed": "failedResults",
|
|
28
|
+
"unprocessed": "unprocessedrecords",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BulkClient:
|
|
33
|
+
"""Bulk API 2.0 ingest job lifecycle: create → upload → close → results."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, base: ApiClientBase):
|
|
36
|
+
self._client = base
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def _jobs_base(self) -> str:
|
|
40
|
+
return f"{self._client.data_base}/jobs/ingest"
|
|
41
|
+
|
|
42
|
+
def create_ingest_job(
|
|
43
|
+
self,
|
|
44
|
+
sobject: str,
|
|
45
|
+
operation: str,
|
|
46
|
+
external_id_field: str | None = None,
|
|
47
|
+
line_ending: str = "LF",
|
|
48
|
+
column_delimiter: str = "COMMA",
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
"""Create an ingest job (insert/update/upsert/delete/hardDelete).
|
|
51
|
+
|
|
52
|
+
``upsert`` requires ``external_id_field``. Delete operations are
|
|
53
|
+
gated by ``allow_destructive``.
|
|
54
|
+
"""
|
|
55
|
+
if operation not in INGEST_OPERATIONS:
|
|
56
|
+
raise SalesforceBadRequestError(
|
|
57
|
+
f"Unknown bulk operation {operation!r}; "
|
|
58
|
+
f"expected one of {sorted(INGEST_OPERATIONS)}."
|
|
59
|
+
)
|
|
60
|
+
if operation in DESTRUCTIVE_OPERATIONS:
|
|
61
|
+
if not self._client.auth.config.allow_destructive:
|
|
62
|
+
raise DestructiveOperationBlockedError(f"bulk.{operation}")
|
|
63
|
+
if operation == "upsert" and not external_id_field:
|
|
64
|
+
raise SalesforceBadRequestError("Bulk upsert requires external_id_field.")
|
|
65
|
+
body: dict[str, Any] = {
|
|
66
|
+
"object": sobject,
|
|
67
|
+
"operation": operation,
|
|
68
|
+
"contentType": "CSV",
|
|
69
|
+
"lineEnding": line_ending,
|
|
70
|
+
"columnDelimiter": column_delimiter,
|
|
71
|
+
}
|
|
72
|
+
if external_id_field:
|
|
73
|
+
body["externalIdFieldName"] = external_id_field
|
|
74
|
+
return self._client.request("POST", self._jobs_base, json=body)
|
|
75
|
+
|
|
76
|
+
def upload(self, job_id: str, csv_data: str | bytes) -> dict[str, Any]:
|
|
77
|
+
"""Upload the job's CSV payload (``PUT .../batches``, text/csv)."""
|
|
78
|
+
return self._client.request(
|
|
79
|
+
"PUT",
|
|
80
|
+
f"{self._jobs_base}/{job_id}/batches",
|
|
81
|
+
content=csv_data,
|
|
82
|
+
headers={"Content-Type": "text/csv", "Accept": "application/json"},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def close(self, job_id: str) -> dict[str, Any]:
|
|
86
|
+
"""Mark upload complete so Salesforce starts processing the job."""
|
|
87
|
+
return self._client.request(
|
|
88
|
+
"PATCH", f"{self._jobs_base}/{job_id}", json={"state": "UploadComplete"}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def abort(self, job_id: str) -> dict[str, Any]:
|
|
92
|
+
"""Abort a job that has not finished processing."""
|
|
93
|
+
return self._client.request(
|
|
94
|
+
"PATCH", f"{self._jobs_base}/{job_id}", json={"state": "Aborted"}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def status(self, job_id: str) -> dict[str, Any]:
|
|
98
|
+
"""Job info: state, counts of processed/failed records, timing."""
|
|
99
|
+
return self._client.request("GET", f"{self._jobs_base}/{job_id}")
|
|
100
|
+
|
|
101
|
+
def list_jobs(self) -> dict[str, Any]:
|
|
102
|
+
"""List ingest jobs (most recent first, paged by Salesforce)."""
|
|
103
|
+
return self._client.request("GET", self._jobs_base)
|
|
104
|
+
|
|
105
|
+
def delete_job(self, job_id: str) -> dict[str, Any]:
|
|
106
|
+
"""Delete the job *metadata* (not org data) once it is terminal."""
|
|
107
|
+
return self._client.request("DELETE", f"{self._jobs_base}/{job_id}")
|
|
108
|
+
|
|
109
|
+
def results(
|
|
110
|
+
self,
|
|
111
|
+
job_id: str,
|
|
112
|
+
kind: str = "successful",
|
|
113
|
+
locator: str | None = None,
|
|
114
|
+
max_bytes: int | None = None,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Download job results CSV (successful / failed / unprocessed).
|
|
117
|
+
|
|
118
|
+
The body is capped at ``max_bytes`` (default
|
|
119
|
+
``SALESFORCE_BULK_RESULTS_MAX_BYTES``); a truncated download is
|
|
120
|
+
flagged and carries the ``Sforce-Locator`` header so the caller can
|
|
121
|
+
page through the remainder.
|
|
122
|
+
"""
|
|
123
|
+
if kind not in RESULT_KINDS:
|
|
124
|
+
raise SalesforceBadRequestError(
|
|
125
|
+
f"Unknown result kind {kind!r}; expected one of {sorted(RESULT_KINDS)}."
|
|
126
|
+
)
|
|
127
|
+
cap = max_bytes or self._client.auth.config.bulk_results_max_bytes
|
|
128
|
+
params = {"locator": locator} if locator else None
|
|
129
|
+
response = self._client.request_raw(
|
|
130
|
+
"GET",
|
|
131
|
+
f"{self._jobs_base}/{job_id}/{RESULT_KINDS[kind]}",
|
|
132
|
+
params=params,
|
|
133
|
+
headers={"Accept": "text/csv"},
|
|
134
|
+
)
|
|
135
|
+
body = response.content
|
|
136
|
+
truncated = len(body) > cap
|
|
137
|
+
return {
|
|
138
|
+
"job_id": job_id,
|
|
139
|
+
"kind": kind,
|
|
140
|
+
"content": body[:cap].decode("utf-8", errors="replace"),
|
|
141
|
+
"bytes": min(len(body), cap),
|
|
142
|
+
"truncated": truncated,
|
|
143
|
+
"locator": response.headers.get("Sforce-Locator"),
|
|
144
|
+
"number_of_records": response.headers.get("Sforce-NumberOfRecords"),
|
|
145
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""CONCEPT:SFDC-1.2 Metadata describe, record counts, and org limits client.
|
|
2
|
+
|
|
3
|
+
Resources:
|
|
4
|
+
- Describe Global: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_describeGlobal.htm
|
|
5
|
+
- sObject Describe: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_describe.htm
|
|
6
|
+
- Record Count: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_record_count.htm
|
|
7
|
+
- Limits: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_limits.htm
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from salesforce_agent.api.api_client_base import ApiClientBase
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DescribeClient:
|
|
16
|
+
"""Schema discovery: object lists, field/relationship/picklist metadata."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base: ApiClientBase):
|
|
19
|
+
self._client = base
|
|
20
|
+
|
|
21
|
+
def global_describe(self) -> dict[str, Any]:
|
|
22
|
+
"""List every sObject available to the integration user."""
|
|
23
|
+
return self._client.request("GET", f"{self._client.data_base}/sobjects")
|
|
24
|
+
|
|
25
|
+
def sobject(self, name: str) -> dict[str, Any]:
|
|
26
|
+
"""Full describe of one sObject — fields, relationships, picklists."""
|
|
27
|
+
return self._client.request(
|
|
28
|
+
"GET", f"{self._client.data_base}/sobjects/{name}/describe"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def record_counts(self, sobjects: list[str] | None = None) -> dict[str, Any]:
|
|
32
|
+
"""Approximate record counts, optionally for selected sObjects."""
|
|
33
|
+
params = {"sObjects": ",".join(sobjects)} if sobjects else None
|
|
34
|
+
return self._client.request(
|
|
35
|
+
"GET", f"{self._client.data_base}/limits/recordCount", params=params
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def limits(self) -> dict[str, Any]:
|
|
39
|
+
"""Org limits and current API usage (DailyApiRequests etc.)."""
|
|
40
|
+
return self._client.request("GET", f"{self._client.data_base}/limits")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""CONCEPT:SFDC-1.2 SOQL / SOSL query client with auto-pagination.
|
|
2
|
+
|
|
3
|
+
Resources:
|
|
4
|
+
- Query: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_query.htm
|
|
5
|
+
- QueryAll: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_queryall.htm
|
|
6
|
+
- Explain: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query_explain.htm
|
|
7
|
+
- Search: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_search.htm
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from salesforce_agent.api.api_client_base import ApiClientBase
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QueryClient:
|
|
16
|
+
"""SOQL queries (with ``nextRecordsUrl`` pagination) and SOSL search."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base: ApiClientBase):
|
|
19
|
+
self._client = base
|
|
20
|
+
|
|
21
|
+
def query(
|
|
22
|
+
self,
|
|
23
|
+
soql: str,
|
|
24
|
+
*,
|
|
25
|
+
query_all: bool = False,
|
|
26
|
+
max_records: int | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Run a SOQL query, following ``nextRecordsUrl`` pages up to a cap.
|
|
29
|
+
|
|
30
|
+
``query_all=True`` uses the ``queryAll`` resource, which includes
|
|
31
|
+
soft-deleted and archived rows. ``max_records`` caps the records
|
|
32
|
+
gathered per call (default: ``SALESFORCE_MAX_QUERY_RECORDS``); when
|
|
33
|
+
the cap stops pagination early the result is flagged ``truncated``
|
|
34
|
+
and carries the unfollowed ``nextRecordsUrl``.
|
|
35
|
+
"""
|
|
36
|
+
cap = max_records or self._client.auth.config.max_query_records
|
|
37
|
+
resource = "queryAll" if query_all else "query"
|
|
38
|
+
page = self._client.request(
|
|
39
|
+
"GET", f"{self._client.data_base}/{resource}", params={"q": soql}
|
|
40
|
+
)
|
|
41
|
+
records: list[dict[str, Any]] = list(page.get("records", []))
|
|
42
|
+
while not page.get("done", True) and len(records) < cap:
|
|
43
|
+
page = self._client.request("GET", page["nextRecordsUrl"])
|
|
44
|
+
records.extend(page.get("records", []))
|
|
45
|
+
|
|
46
|
+
truncated = len(records) > cap or not page.get("done", True)
|
|
47
|
+
return {
|
|
48
|
+
"totalSize": page.get("totalSize", len(records)),
|
|
49
|
+
"done": page.get("done", True),
|
|
50
|
+
"records": records[:cap],
|
|
51
|
+
"returned": min(len(records), cap),
|
|
52
|
+
"truncated": truncated,
|
|
53
|
+
"nextRecordsUrl": None
|
|
54
|
+
if page.get("done", True)
|
|
55
|
+
else page.get("nextRecordsUrl"),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def explain(self, soql: str) -> dict[str, Any]:
|
|
59
|
+
"""Return the query plan for a SOQL statement (``?explain=``)."""
|
|
60
|
+
return self._client.request(
|
|
61
|
+
"GET", f"{self._client.data_base}/query/", params={"explain": soql}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def search(self, sosl: str) -> dict[str, Any]:
|
|
65
|
+
"""Run a SOSL full-text search (``/search/?q=FIND {...}``)."""
|
|
66
|
+
return self._client.request(
|
|
67
|
+
"GET", f"{self._client.data_base}/search/", params={"q": sosl}
|
|
68
|
+
)
|