salesforce-data-customcode 6.0.8.dev1__py3-none-any.whl → 6.1.0.dev2__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.
- datacustomcode/client.py +92 -0
- datacustomcode/config.yaml +6 -0
- datacustomcode/deploy.py +7 -2
- datacustomcode/einstein_predictions/spark_default.py +88 -27
- datacustomcode/function/runtime.py +16 -0
- datacustomcode/named_credential/__init__.py +26 -0
- datacustomcode/named_credential/base.py +54 -0
- datacustomcode/named_credential/default.py +93 -0
- datacustomcode/named_credential/direct/__init__.py +19 -0
- datacustomcode/named_credential/direct/auth.py +63 -0
- datacustomcode/named_credential/direct/credentials.py +121 -0
- datacustomcode/named_credential/direct/transport.py +110 -0
- datacustomcode/named_credential/direct/url_resolver.py +112 -0
- datacustomcode/named_credential/spark_base.py +93 -0
- datacustomcode/named_credential/spark_default.py +154 -0
- datacustomcode/named_credential/types/__init__.py +14 -0
- datacustomcode/named_credential/types/http_method.py +29 -0
- datacustomcode/named_credential/types/http_request.py +63 -0
- datacustomcode/named_credential/types/http_request_builder.py +55 -0
- datacustomcode/named_credential/types/http_response.py +43 -0
- datacustomcode/named_credential/types/http_response_builder.py +24 -0
- datacustomcode/named_credential_config.py +105 -0
- datacustomcode/run.py +7 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/README.md +119 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/config.json +3 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/entrypoint.py +161 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/external_callout_config.json +11 -0
- datacustomcode/templates/function/example/chunking_with_external_callout/tests/test.json +16 -0
- {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/METADATA +1 -1
- {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/RECORD +33 -11
- {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/WHEEL +0 -0
- {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/entry_points.txt +0 -0
- {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/licenses/LICENSE.txt +0 -0
datacustomcode/client.py
CHANGED
|
@@ -29,6 +29,7 @@ from datacustomcode.einstein_predictions_config import spark_einstein_prediction
|
|
|
29
29
|
from datacustomcode.file.path.default import DefaultFindFilePath
|
|
30
30
|
from datacustomcode.io.reader.base import BaseDataCloudReader
|
|
31
31
|
from datacustomcode.llm_gateway_config import spark_llm_gateway_config
|
|
32
|
+
from datacustomcode.named_credential_config import spark_named_credential_config
|
|
32
33
|
from datacustomcode.spark.default import DefaultSparkSessionProvider
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
@@ -41,6 +42,9 @@ if TYPE_CHECKING:
|
|
|
41
42
|
from datacustomcode.io.reader.base import BaseDataCloudReader
|
|
42
43
|
from datacustomcode.io.writer.base import BaseDataCloudWriter, WriteMode
|
|
43
44
|
from datacustomcode.llm_gateway.spark_base import SparkLLMGateway
|
|
45
|
+
from datacustomcode.named_credential.spark_base import SparkNamedCredential
|
|
46
|
+
from datacustomcode.named_credential.types.http_request import HTTPRequest
|
|
47
|
+
from datacustomcode.named_credential.types.http_response import HTTPResponse
|
|
44
48
|
from datacustomcode.spark.base import BaseSparkSessionProvider
|
|
45
49
|
|
|
46
50
|
|
|
@@ -118,6 +122,21 @@ def _build_spark_einstein_predictions() -> "SparkEinsteinPredictions":
|
|
|
118
122
|
return cfg.to_object()
|
|
119
123
|
|
|
120
124
|
|
|
125
|
+
def _build_spark_named_credential() -> "SparkNamedCredential":
|
|
126
|
+
"""Instantiate the SDK-configured :class:`SparkNamedCredential`.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
RuntimeError: If no ``spark_named_credential_config`` has been loaded.
|
|
130
|
+
"""
|
|
131
|
+
cfg = spark_named_credential_config.spark_named_credential_config
|
|
132
|
+
if cfg is None:
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
"spark_named_credential_config is not configured. Add a "
|
|
135
|
+
"'spark_named_credential_config' section to config.yaml."
|
|
136
|
+
)
|
|
137
|
+
return cfg.to_object()
|
|
138
|
+
|
|
139
|
+
|
|
121
140
|
def einstein_predict_col(
|
|
122
141
|
model_api_name: str,
|
|
123
142
|
prediction_type: "PredictionType",
|
|
@@ -167,6 +186,40 @@ def einstein_predict_col(
|
|
|
167
186
|
)
|
|
168
187
|
|
|
169
188
|
|
|
189
|
+
def named_credential_request_col(
|
|
190
|
+
request: "HTTPRequest",
|
|
191
|
+
body: Optional["Column"] = None,
|
|
192
|
+
) -> "Column":
|
|
193
|
+
"""Build a Spark Column that makes one Named Credential callout per row.
|
|
194
|
+
|
|
195
|
+
The endpoint, method, and headers are fixed for the call (taken from
|
|
196
|
+
``request``); only ``body`` varies per row. Use this instead of
|
|
197
|
+
:meth:`Client.named_credential_request` when the callout runs across a
|
|
198
|
+
DataFrame so each row is dispatched independently rather than one-shot on
|
|
199
|
+
the driver.
|
|
200
|
+
|
|
201
|
+
The returned Column yields a struct ``{status, response, error_code,
|
|
202
|
+
error_message}`` for each row. ``response`` is itself a struct
|
|
203
|
+
``{status_code, body, headers}``. Use ``[...]`` to pick a field, e.g.
|
|
204
|
+
``named_credential_request_col(...)["response"]["status_code"]``. A transport
|
|
205
|
+
failure sets ``status`` to ``ERROR`` and populates ``error_message`` (a non-2xx
|
|
206
|
+
HTTP response is still ``SUCCESS`` with its code in ``response.status_code``),
|
|
207
|
+
so a single bad row does not abort the whole Spark job.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
request: The callout template — its symbolic reference, method, and
|
|
211
|
+
headers are applied to every row.
|
|
212
|
+
body: Optional per-row ``Column`` holding the request body as a
|
|
213
|
+
string (or null for no body).
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
A Spark ``Column`` of ``StructType`` with fields ``status``,
|
|
217
|
+
``response``, ``error_code``, and ``error_message``.
|
|
218
|
+
"""
|
|
219
|
+
named_credential = Client()._get_spark_named_credential()
|
|
220
|
+
return named_credential.request_col(request, body=body)
|
|
221
|
+
|
|
222
|
+
|
|
170
223
|
class DataCloudObjectType(Enum):
|
|
171
224
|
DLO = "dlo"
|
|
172
225
|
DMO = "dmo"
|
|
@@ -228,6 +281,7 @@ class Client:
|
|
|
228
281
|
spark_llm_gateway: Optional custom :class:`SparkLLMGateway`.
|
|
229
282
|
spark_einstein_predictions: Optional custom
|
|
230
283
|
:class:`SparkEinsteinPredictions`.
|
|
284
|
+
spark_named_credential: Optional custom :class:`SparkNamedCredential`.
|
|
231
285
|
|
|
232
286
|
Example:
|
|
233
287
|
>>> client = Client()
|
|
@@ -243,6 +297,7 @@ class Client:
|
|
|
243
297
|
_file: DefaultFindFilePath
|
|
244
298
|
_spark_llm_gateway: Optional[SparkLLMGateway]
|
|
245
299
|
_spark_einstein_predictions: Optional[SparkEinsteinPredictions]
|
|
300
|
+
_spark_named_credential: Optional[SparkNamedCredential]
|
|
246
301
|
_data_layer_history: dict[DataCloudObjectType, set[str]]
|
|
247
302
|
_code_type: str
|
|
248
303
|
|
|
@@ -253,6 +308,7 @@ class Client:
|
|
|
253
308
|
spark_provider: Optional[BaseSparkSessionProvider] = None,
|
|
254
309
|
spark_llm_gateway: Optional[SparkLLMGateway] = None,
|
|
255
310
|
spark_einstein_predictions: Optional[SparkEinsteinPredictions] = None,
|
|
311
|
+
spark_named_credential: Optional[SparkNamedCredential] = None,
|
|
256
312
|
code_type: str = "script",
|
|
257
313
|
) -> Client:
|
|
258
314
|
|
|
@@ -260,6 +316,7 @@ class Client:
|
|
|
260
316
|
cls._instance = super().__new__(cls)
|
|
261
317
|
cls._instance._spark_llm_gateway = spark_llm_gateway
|
|
262
318
|
cls._instance._spark_einstein_predictions = spark_einstein_predictions
|
|
319
|
+
cls._instance._spark_named_credential = spark_named_credential
|
|
263
320
|
# Initialize Readers and Writers from config
|
|
264
321
|
# and/or provided reader and writer
|
|
265
322
|
if reader is None or writer is None:
|
|
@@ -474,6 +531,41 @@ class Client:
|
|
|
474
531
|
self._spark_einstein_predictions = _build_spark_einstein_predictions()
|
|
475
532
|
return self._spark_einstein_predictions
|
|
476
533
|
|
|
534
|
+
def named_credential_request(
|
|
535
|
+
self,
|
|
536
|
+
request: "HTTPRequest",
|
|
537
|
+
body: Optional[str] = None,
|
|
538
|
+
) -> "HTTPResponse":
|
|
539
|
+
"""Issue a one-shot Named Credential external callout. This is the
|
|
540
|
+
scalar counterpart to :func:`named_credential_request_col`: it runs
|
|
541
|
+
**once** on the driver — not per row. Use the column helper method
|
|
542
|
+
instead when you want to fan a callout out across every row of a
|
|
543
|
+
DataFrame.
|
|
544
|
+
|
|
545
|
+
Example:
|
|
546
|
+
|
|
547
|
+
>>> from datacustomcode.named_credential.types.http_request_builder \\
|
|
548
|
+
... import HTTPRequestBuilder
|
|
549
|
+
>>> request = (
|
|
550
|
+
... HTTPRequestBuilder().set_url("callout:NC/search").build()
|
|
551
|
+
... )
|
|
552
|
+
>>> response = Client().named_credential_request(request)
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
request: The callout request
|
|
556
|
+
body: Optional request body. Set the ``Content-Type`` header to
|
|
557
|
+
match the format; the SDK does not assume or inject one.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
The external service's response.
|
|
561
|
+
"""
|
|
562
|
+
return self._get_spark_named_credential().request(request, body=body)
|
|
563
|
+
|
|
564
|
+
def _get_spark_named_credential(self) -> SparkNamedCredential:
|
|
565
|
+
if self._spark_named_credential is None:
|
|
566
|
+
self._spark_named_credential = _build_spark_named_credential()
|
|
567
|
+
return self._spark_named_credential
|
|
568
|
+
|
|
477
569
|
def _validate_data_layer_history_does_not_contain(
|
|
478
570
|
self, data_cloud_object_type: DataCloudObjectType
|
|
479
571
|
) -> None:
|
datacustomcode/config.yaml
CHANGED
datacustomcode/deploy.py
CHANGED
|
@@ -37,6 +37,9 @@ import requests
|
|
|
37
37
|
|
|
38
38
|
from datacustomcode.cmd import cmd_output
|
|
39
39
|
from datacustomcode.constants import REQUEST_TYPE_TO_FEATURE
|
|
40
|
+
from datacustomcode.named_credential.direct.credentials import (
|
|
41
|
+
EXTERNAL_CALLOUT_CREDENTIAL,
|
|
42
|
+
)
|
|
40
43
|
from datacustomcode.scan import find_base_directory, get_package_type
|
|
41
44
|
|
|
42
45
|
DATA_CUSTOM_CODE_PATH = "services/data/v63.0/ssot/data-custom-code"
|
|
@@ -236,6 +239,8 @@ DEPENDENCIES_ARCHIVE_PATH = os.path.join(
|
|
|
236
239
|
)
|
|
237
240
|
PY_FILES_PATH = os.path.join("payload", "py-files")
|
|
238
241
|
ZIP_FILE_NAME = "deployment.zip"
|
|
242
|
+
# Local-only files that must never be packaged into the deployment zip.
|
|
243
|
+
EXCLUDED_FILES = (".DS_Store", EXTERNAL_CALLOUT_CREDENTIAL)
|
|
239
244
|
|
|
240
245
|
|
|
241
246
|
def prepare_dependency_archive(
|
|
@@ -598,9 +603,9 @@ def zip(
|
|
|
598
603
|
|
|
599
604
|
with zipfile.ZipFile(ZIP_FILE_NAME, "w", zipfile.ZIP_DEFLATED) as zipf:
|
|
600
605
|
for root, dirs, files in os.walk(directory):
|
|
601
|
-
# Skip .DS_Store
|
|
606
|
+
# Skip .DS_Store and local credentials.
|
|
602
607
|
for file in files:
|
|
603
|
-
if file
|
|
608
|
+
if file not in EXCLUDED_FILES:
|
|
604
609
|
abs_path = os.path.join(root, file)
|
|
605
610
|
arcname = os.path.relpath(abs_path, directory)
|
|
606
611
|
zipf.write(abs_path, arcname)
|
|
@@ -39,6 +39,9 @@ if TYPE_CHECKING:
|
|
|
39
39
|
_STATUS_SUCCESS = "SUCCESS"
|
|
40
40
|
_STATUS_ERROR = "ERROR"
|
|
41
41
|
|
|
42
|
+
# HTTP status considered a successful prediction call.
|
|
43
|
+
_HTTP_OK = 200
|
|
44
|
+
|
|
42
45
|
|
|
43
46
|
class DefaultSparkEinsteinPredictions(SparkEinsteinPredictions):
|
|
44
47
|
|
|
@@ -104,6 +107,8 @@ class DefaultSparkEinsteinPredictions(SparkEinsteinPredictions):
|
|
|
104
107
|
|
|
105
108
|
def _predict(values_row: Any) -> Dict[str, Optional[str]]:
|
|
106
109
|
if values_row is None:
|
|
110
|
+
# An entirely null features struct is not the normal per-feature null
|
|
111
|
+
# case; surface it directly rather than masking it (debuggability).
|
|
107
112
|
return {
|
|
108
113
|
"status": _STATUS_ERROR,
|
|
109
114
|
"response": None,
|
|
@@ -181,6 +186,21 @@ def _call_predictions(
|
|
|
181
186
|
return predictions.predict(request)
|
|
182
187
|
|
|
183
188
|
|
|
189
|
+
def _null_feature_name(features: Dict[str, Any]) -> Optional[str]:
|
|
190
|
+
"""Return the name of the first null feature value, or ``None``."""
|
|
191
|
+
for name, value in features.items():
|
|
192
|
+
if value is None:
|
|
193
|
+
return name
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _null_feature_message(name: str) -> str:
|
|
198
|
+
return (
|
|
199
|
+
f"Feature '{name}' has null value. Use coalesce() or when() to handle "
|
|
200
|
+
f"nulls before calling einstein_predict."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
184
204
|
def _invoke_predictions(
|
|
185
205
|
predictions: "EinsteinPredictions",
|
|
186
206
|
model_api_name: str,
|
|
@@ -190,18 +210,40 @@ def _invoke_predictions(
|
|
|
190
210
|
) -> Dict[str, Any]:
|
|
191
211
|
from datacustomcode.einstein_predictions.errors import EinsteinPredictionsCallError
|
|
192
212
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
null_feature = _null_feature_name(features)
|
|
214
|
+
if null_feature is not None:
|
|
215
|
+
message = _null_feature_message(null_feature)
|
|
216
|
+
raise EinsteinPredictionsCallError(
|
|
217
|
+
f"Einstein Predictions call failed: {message}",
|
|
218
|
+
status=None,
|
|
219
|
+
error_code=None,
|
|
220
|
+
error_message=message,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
response = _call_predictions(
|
|
225
|
+
predictions, model_api_name, prediction_type, features, settings
|
|
226
|
+
)
|
|
227
|
+
except EinsteinPredictionsCallError:
|
|
228
|
+
raise
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
# Transport/build failures: surface the real error (no masking) so local
|
|
231
|
+
# runs stay debuggable. error_code stays None since there is no HTTP status.
|
|
232
|
+
raise EinsteinPredictionsCallError(
|
|
233
|
+
f"Einstein Predictions call failed: {exc}",
|
|
234
|
+
status=None,
|
|
235
|
+
error_code=None,
|
|
236
|
+
error_message=str(exc),
|
|
237
|
+
) from exc
|
|
238
|
+
|
|
239
|
+
if response.status_code != _HTTP_OK:
|
|
240
|
+
error_message = json.dumps(response.data) if response.data is not None else None
|
|
198
241
|
raise EinsteinPredictionsCallError(
|
|
199
242
|
f"Einstein Predictions call failed: "
|
|
200
|
-
f"status_code={response.status_code}, "
|
|
201
|
-
f"error_code={error_code!r}, message={response.data!r}",
|
|
243
|
+
f"status_code={response.status_code}, message={error_message!r}",
|
|
202
244
|
status=response.status_code,
|
|
203
|
-
error_code=
|
|
204
|
-
error_message=
|
|
245
|
+
error_code=str(response.status_code),
|
|
246
|
+
error_message=error_message,
|
|
205
247
|
)
|
|
206
248
|
return response.data or {}
|
|
207
249
|
|
|
@@ -213,27 +255,46 @@ def _invoke_predictions_as_struct(
|
|
|
213
255
|
features: Dict[str, Any],
|
|
214
256
|
settings: Optional[Dict[str, Any]],
|
|
215
257
|
) -> Dict[str, Optional[str]]:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if not response.is_success:
|
|
258
|
+
# (a) Customer-actionable data condition — surface the actionable message directly.
|
|
259
|
+
null_feature = _null_feature_name(features)
|
|
260
|
+
if null_feature is not None:
|
|
220
261
|
return {
|
|
221
262
|
"status": _STATUS_ERROR,
|
|
222
263
|
"response": None,
|
|
223
|
-
"error_code":
|
|
224
|
-
"error_message":
|
|
264
|
+
"error_code": None,
|
|
265
|
+
"error_message": _null_feature_message(null_feature),
|
|
225
266
|
}
|
|
226
|
-
return {
|
|
227
|
-
"status": _STATUS_SUCCESS,
|
|
228
|
-
"response": json.dumps(response.data) if response.data is not None else None,
|
|
229
|
-
"error_code": None,
|
|
230
|
-
"error_message": None,
|
|
231
|
-
}
|
|
232
267
|
|
|
268
|
+
# (b) Transport/build failures — surface the real error (no masking) so local
|
|
269
|
+
# runs stay debuggable. error_code stays None since there is no HTTP status.
|
|
270
|
+
try:
|
|
271
|
+
response = _call_predictions(
|
|
272
|
+
predictions, model_api_name, prediction_type, features, settings
|
|
273
|
+
)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
return {
|
|
276
|
+
"status": _STATUS_ERROR,
|
|
277
|
+
"response": None,
|
|
278
|
+
"error_code": None,
|
|
279
|
+
"error_message": str(exc),
|
|
280
|
+
}
|
|
233
281
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
282
|
+
if response.status_code == _HTTP_OK:
|
|
283
|
+
return {
|
|
284
|
+
"status": _STATUS_SUCCESS,
|
|
285
|
+
"response": (
|
|
286
|
+
json.dumps(response.data) if response.data is not None else None
|
|
287
|
+
),
|
|
288
|
+
"error_code": None,
|
|
289
|
+
"error_message": None,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# (c) Non-200 SFAP HTTP error: error_code = status code, error_message = data JSON.
|
|
293
|
+
return {
|
|
294
|
+
"status": _STATUS_ERROR,
|
|
295
|
+
"response": None,
|
|
296
|
+
"error_code": str(response.status_code),
|
|
297
|
+
"error_message": (
|
|
298
|
+
json.dumps(response.data) if response.data is not None else None
|
|
299
|
+
),
|
|
300
|
+
}
|
|
@@ -23,6 +23,8 @@ from datacustomcode.file.path.default import DefaultFindFilePath
|
|
|
23
23
|
from datacustomcode.function.base import BaseRuntime
|
|
24
24
|
from datacustomcode.llm_gateway.base import LLMGateway
|
|
25
25
|
from datacustomcode.llm_gateway_config import llm_gateway_config
|
|
26
|
+
from datacustomcode.named_credential.base import NamedCredential
|
|
27
|
+
from datacustomcode.named_credential_config import named_credential_config
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
class Runtime(BaseRuntime):
|
|
@@ -69,6 +71,7 @@ class Runtime(BaseRuntime):
|
|
|
69
71
|
self._llm_gateway: Optional[LLMGateway] = None
|
|
70
72
|
self._file = DefaultFindFilePath()
|
|
71
73
|
self._einstein_predictions: Optional[EinsteinPredictions] = None
|
|
74
|
+
self._named_credential: Optional[NamedCredential] = None
|
|
72
75
|
|
|
73
76
|
@property
|
|
74
77
|
def llm_gateway(self) -> LLMGateway:
|
|
@@ -98,3 +101,16 @@ class Runtime(BaseRuntime):
|
|
|
98
101
|
einstein_predictions_config.einstein_predictions_config.to_object()
|
|
99
102
|
)
|
|
100
103
|
return self._einstein_predictions
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def named_credential(self) -> NamedCredential:
|
|
107
|
+
if self._named_credential is None:
|
|
108
|
+
if named_credential_config.named_credential_config is None:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"Named Credential is not configured. Add "
|
|
111
|
+
"'named_credential_config' section to config.yaml"
|
|
112
|
+
)
|
|
113
|
+
self._named_credential = (
|
|
114
|
+
named_credential_config.named_credential_config.to_object()
|
|
115
|
+
)
|
|
116
|
+
return self._named_credential
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
from datacustomcode.named_credential.base import NamedCredential
|
|
17
|
+
from datacustomcode.named_credential.default import DefaultNamedCredential
|
|
18
|
+
from datacustomcode.named_credential.spark_base import SparkNamedCredential
|
|
19
|
+
from datacustomcode.named_credential.spark_default import DefaultSparkNamedCredential
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DefaultNamedCredential",
|
|
23
|
+
"DefaultSparkNamedCredential",
|
|
24
|
+
"NamedCredential",
|
|
25
|
+
"SparkNamedCredential",
|
|
26
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import TYPE_CHECKING, Optional
|
|
19
|
+
|
|
20
|
+
from datacustomcode.mixin import UserExtendableNamedConfigMixin
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from datacustomcode.named_credential.types.http_request import HTTPRequest
|
|
24
|
+
from datacustomcode.named_credential.types.http_response import HTTPResponse
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NamedCredential(ABC, UserExtendableNamedConfigMixin):
|
|
28
|
+
CONFIG_NAME: str
|
|
29
|
+
|
|
30
|
+
def __init__(self, **kwargs):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def request(
|
|
35
|
+
self,
|
|
36
|
+
request: HTTPRequest,
|
|
37
|
+
body: Optional[str] = None,
|
|
38
|
+
) -> HTTPResponse:
|
|
39
|
+
"""Make an external callout through a Named Credential.
|
|
40
|
+
|
|
41
|
+
The endpoint and its authentication are resolved server-side from the
|
|
42
|
+
Named Credential referenced by ``request.url``; the function never sees
|
|
43
|
+
the external credential.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
request: The callout request
|
|
47
|
+
body: Optional request body. Set the
|
|
48
|
+
``Content-Type`` header to match the format; the SDK
|
|
49
|
+
does not assume or inject one.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The external service's response.
|
|
53
|
+
"""
|
|
54
|
+
...
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
from typing import (
|
|
17
|
+
Any,
|
|
18
|
+
Dict,
|
|
19
|
+
Optional,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from datacustomcode.named_credential.base import NamedCredential
|
|
23
|
+
from datacustomcode.named_credential.types.http_request import HTTPRequest
|
|
24
|
+
from datacustomcode.named_credential.types.http_response import HTTPResponse
|
|
25
|
+
from datacustomcode.named_credential.types.http_response_builder import (
|
|
26
|
+
HTTPResponseBuilder,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DefaultNamedCredential(NamedCredential):
|
|
31
|
+
"""
|
|
32
|
+
Executes the callout directly via :class:`DirectCalloutTransport`, resolving
|
|
33
|
+
the URL from the Named Credential Connect API (falling back to
|
|
34
|
+
``external_callout_config.json``) and injecting auth from the same file.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
CONFIG_NAME = "DefaultNamedCredential"
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
credentials_profile: str = "default",
|
|
42
|
+
sf_cli_org: Optional[str] = None,
|
|
43
|
+
credential_file: Optional[str] = None,
|
|
44
|
+
**kwargs: Any,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(**kwargs)
|
|
47
|
+
self._credentials_profile = credentials_profile
|
|
48
|
+
self._sf_cli_org = sf_cli_org
|
|
49
|
+
self._credential_file = credential_file
|
|
50
|
+
self._transport: Optional[Any] = None
|
|
51
|
+
|
|
52
|
+
def request(
|
|
53
|
+
self,
|
|
54
|
+
request: HTTPRequest,
|
|
55
|
+
body: Optional[str] = None,
|
|
56
|
+
) -> HTTPResponse:
|
|
57
|
+
# Body and response are treated as opaque strings; the SDK makes no
|
|
58
|
+
# assumption about their format (JSON, XML, text, ...).
|
|
59
|
+
callout_request = {
|
|
60
|
+
"path": request.url,
|
|
61
|
+
"method": request.method,
|
|
62
|
+
"headers": dict(request.headers),
|
|
63
|
+
"body": body if body is not None else "",
|
|
64
|
+
}
|
|
65
|
+
callout_response = self._callout(callout_request)
|
|
66
|
+
return HTTPResponseBuilder.build(
|
|
67
|
+
{
|
|
68
|
+
"status_code": callout_response.get("status_code"),
|
|
69
|
+
"headers": callout_response.get("headers", {}),
|
|
70
|
+
"body": callout_response.get("body") or "",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _callout(self, callout_request: Dict[str, Any]) -> Dict[str, Any]:
|
|
75
|
+
"""Execute the callout via the transport.
|
|
76
|
+
|
|
77
|
+
Returns a dict with ``status_code``, ``headers``, and ``body``.
|
|
78
|
+
"""
|
|
79
|
+
result: Dict[str, Any] = self._get_transport().callout(callout_request)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def _get_transport(self) -> Any:
|
|
83
|
+
if self._transport is None:
|
|
84
|
+
from datacustomcode.named_credential.direct.transport import (
|
|
85
|
+
DirectCalloutTransport,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self._transport = DirectCalloutTransport(
|
|
89
|
+
credentials_profile=self._credentials_profile,
|
|
90
|
+
sf_cli_org=self._sf_cli_org,
|
|
91
|
+
credential_file=self._credential_file,
|
|
92
|
+
)
|
|
93
|
+
return self._transport
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Named Credential callout path.
|
|
16
|
+
|
|
17
|
+
This resolves the endpoint via the NamedCredential Connect API
|
|
18
|
+
and injects auth from a developer-provided ``external_callout_config.json``
|
|
19
|
+
"""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright (c) 2025, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
Inject external credential auth into an outgoing request.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
from typing import (
|
|
23
|
+
TYPE_CHECKING,
|
|
24
|
+
Any,
|
|
25
|
+
Dict,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from requests.auth import AuthBase
|
|
29
|
+
|
|
30
|
+
from datacustomcode.named_credential.direct.credentials import AuthType
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from requests.models import PreparedRequest
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DynamicAuthHandler(AuthBase):
|
|
37
|
+
def __init__(self, cred_config: Dict[str, Any]) -> None:
|
|
38
|
+
self.config = cred_config
|
|
39
|
+
self.auth_type = cred_config.get("auth_type")
|
|
40
|
+
|
|
41
|
+
def __call__(self, request: PreparedRequest) -> PreparedRequest:
|
|
42
|
+
if self.auth_type == AuthType.BASIC.value:
|
|
43
|
+
user = self.config.get("username", "")
|
|
44
|
+
pwd = self.config.get("password", "")
|
|
45
|
+
token = base64.b64encode(f"{user}:{pwd}".encode()).decode()
|
|
46
|
+
request.headers["Authorization"] = f"Basic {token}"
|
|
47
|
+
|
|
48
|
+
elif self.auth_type == AuthType.CUSTOM.value:
|
|
49
|
+
for name, value in self.config.get("custom_headers", {}).items():
|
|
50
|
+
request.headers[name] = value
|
|
51
|
+
|
|
52
|
+
elif self.auth_type in (AuthType.OAUTH.value, AuthType.JWT.value):
|
|
53
|
+
bearer = self.config.get("access_token") or self.config.get("token")
|
|
54
|
+
if not bearer:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"'{self.auth_type}' auth requires an 'access_token' or 'token'."
|
|
57
|
+
)
|
|
58
|
+
request.headers["Authorization"] = f"Bearer {bearer}"
|
|
59
|
+
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(f"Unsupported auth_type '{self.auth_type}'.")
|
|
62
|
+
|
|
63
|
+
return request
|