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.
Files changed (33) hide show
  1. datacustomcode/client.py +92 -0
  2. datacustomcode/config.yaml +6 -0
  3. datacustomcode/deploy.py +7 -2
  4. datacustomcode/einstein_predictions/spark_default.py +88 -27
  5. datacustomcode/function/runtime.py +16 -0
  6. datacustomcode/named_credential/__init__.py +26 -0
  7. datacustomcode/named_credential/base.py +54 -0
  8. datacustomcode/named_credential/default.py +93 -0
  9. datacustomcode/named_credential/direct/__init__.py +19 -0
  10. datacustomcode/named_credential/direct/auth.py +63 -0
  11. datacustomcode/named_credential/direct/credentials.py +121 -0
  12. datacustomcode/named_credential/direct/transport.py +110 -0
  13. datacustomcode/named_credential/direct/url_resolver.py +112 -0
  14. datacustomcode/named_credential/spark_base.py +93 -0
  15. datacustomcode/named_credential/spark_default.py +154 -0
  16. datacustomcode/named_credential/types/__init__.py +14 -0
  17. datacustomcode/named_credential/types/http_method.py +29 -0
  18. datacustomcode/named_credential/types/http_request.py +63 -0
  19. datacustomcode/named_credential/types/http_request_builder.py +55 -0
  20. datacustomcode/named_credential/types/http_response.py +43 -0
  21. datacustomcode/named_credential/types/http_response_builder.py +24 -0
  22. datacustomcode/named_credential_config.py +105 -0
  23. datacustomcode/run.py +7 -0
  24. datacustomcode/templates/function/example/chunking_with_external_callout/README.md +119 -0
  25. datacustomcode/templates/function/example/chunking_with_external_callout/config.json +3 -0
  26. datacustomcode/templates/function/example/chunking_with_external_callout/entrypoint.py +161 -0
  27. datacustomcode/templates/function/example/chunking_with_external_callout/external_callout_config.json +11 -0
  28. datacustomcode/templates/function/example/chunking_with_external_callout/tests/test.json +16 -0
  29. {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/METADATA +1 -1
  30. {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/RECORD +33 -11
  31. {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/WHEEL +0 -0
  32. {salesforce_data_customcode-6.0.8.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/entry_points.txt +0 -0
  33. {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:
@@ -34,3 +34,9 @@ llm_gateway_config:
34
34
 
35
35
  spark_llm_gateway_config:
36
36
  type_config_name: DefaultSparkLLMGateway
37
+
38
+ named_credential_config:
39
+ type_config_name: DefaultNamedCredential
40
+
41
+ spark_named_credential_config:
42
+ type_config_name: DefaultSparkNamedCredential
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 files when adding to zip
606
+ # Skip .DS_Store and local credentials.
602
607
  for file in files:
603
- if file != ".DS_Store":
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
- response = _call_predictions(
194
- predictions, model_api_name, prediction_type, features, settings
195
- )
196
- if not response.is_success:
197
- error_code = _extract_error_code(response)
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=error_code,
204
- error_message=str(response.data) if response.data else None,
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
- response = _call_predictions(
217
- predictions, model_api_name, prediction_type, features, settings
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": _extract_error_code(response),
224
- "error_message": str(response.data) if response.data else None,
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
- def _extract_error_code(response: "PredictionResponse") -> Optional[str]:
235
- if response.data:
236
- error_code = response.data.get("errorCode")
237
- if error_code is not None:
238
- return str(error_code)
239
- return None
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