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.
@@ -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,4 @@
1
+ from salesforce_agent.agent_server import agent_server
2
+
3
+ if __name__ == "__main__":
4
+ agent_server()
@@ -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
+ )