salesforce-data-customcode 6.1.0.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 (41) hide show
  1. datacustomcode/__init__.py +0 -5
  2. datacustomcode/cli.py +4 -29
  3. datacustomcode/client.py +192 -211
  4. datacustomcode/config.py +0 -5
  5. datacustomcode/config.yaml +6 -0
  6. datacustomcode/constants.py +0 -8
  7. datacustomcode/deploy.py +23 -57
  8. datacustomcode/function/runtime.py +16 -0
  9. datacustomcode/io/reader/base.py +0 -42
  10. datacustomcode/io/writer/base.py +0 -33
  11. datacustomcode/named_credential/__init__.py +26 -0
  12. datacustomcode/named_credential/base.py +54 -0
  13. datacustomcode/named_credential/default.py +93 -0
  14. datacustomcode/named_credential/direct/__init__.py +19 -0
  15. datacustomcode/named_credential/direct/auth.py +63 -0
  16. datacustomcode/named_credential/direct/credentials.py +121 -0
  17. datacustomcode/named_credential/direct/transport.py +110 -0
  18. datacustomcode/named_credential/direct/url_resolver.py +112 -0
  19. datacustomcode/named_credential/spark_base.py +93 -0
  20. datacustomcode/named_credential/spark_default.py +154 -0
  21. datacustomcode/named_credential/types/__init__.py +14 -0
  22. datacustomcode/named_credential/types/http_method.py +29 -0
  23. datacustomcode/named_credential/types/http_request.py +63 -0
  24. datacustomcode/named_credential/types/http_request_builder.py +55 -0
  25. datacustomcode/named_credential/types/http_response.py +43 -0
  26. datacustomcode/named_credential/types/http_response_builder.py +24 -0
  27. datacustomcode/named_credential_config.py +105 -0
  28. datacustomcode/run.py +7 -11
  29. datacustomcode/scan.py +29 -164
  30. datacustomcode/template.py +1 -13
  31. datacustomcode/templates/function/example/chunking_with_external_callout/README.md +119 -0
  32. datacustomcode/templates/function/example/chunking_with_external_callout/config.json +3 -0
  33. datacustomcode/templates/function/example/chunking_with_external_callout/entrypoint.py +161 -0
  34. datacustomcode/templates/function/example/chunking_with_external_callout/external_callout_config.json +11 -0
  35. datacustomcode/templates/function/example/chunking_with_external_callout/tests/test.json +16 -0
  36. {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/METADATA +3 -40
  37. {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/RECORD +40 -19
  38. datacustomcode/templates/script/examples/streaming_deltas/entrypoint.py +0 -49
  39. {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/WHEEL +0 -0
  40. {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/entry_points.txt +0 -0
  41. {salesforce_data_customcode-6.1.0.dev1.dist-info → salesforce_data_customcode-6.1.0.dev2.dist-info}/licenses/LICENSE.txt +0 -0
datacustomcode/deploy.py CHANGED
@@ -14,7 +14,6 @@
14
14
  # limitations under the License.
15
15
  from __future__ import annotations
16
16
 
17
- import copy
18
17
  from html import unescape
19
18
  import json
20
19
  import os
@@ -38,11 +37,13 @@ import requests
38
37
 
39
38
  from datacustomcode.cmd import cmd_output
40
39
  from datacustomcode.constants import REQUEST_TYPE_TO_FEATURE
40
+ from datacustomcode.named_credential.direct.credentials import (
41
+ EXTERNAL_CALLOUT_CREDENTIAL,
42
+ )
41
43
  from datacustomcode.scan import find_base_directory, get_package_type
42
44
 
43
45
  DATA_CUSTOM_CODE_PATH = "services/data/v63.0/ssot/data-custom-code"
44
46
  DATA_TRANSFORMS_PATH = "services/data/v63.0/ssot/data-transforms"
45
- DATA_CUSTOM_CODE_INVOKE_OPTIONS_PATH = "services/data/v67.0/ssot/data-custom-code"
46
47
  WAIT_FOR_DEPLOYMENT_TIMEOUT = 3000
47
48
 
48
49
  # Available compute types for Data Cloud deployments.
@@ -110,7 +111,6 @@ class CodeExtensionMetadata(BaseModel):
110
111
  computeType: str
111
112
  codeType: str
112
113
  functionInvokeOptions: Union[list[str], None] = None
113
- invokeOptions: Union[list[str], None] = None
114
114
 
115
115
  def __init__(self, **data):
116
116
  name = data.get("name", "")
@@ -203,14 +203,7 @@ def create_deployment(
203
203
  access_token: AccessTokenResponse, metadata: CodeExtensionMetadata
204
204
  ) -> CreateDeploymentResponse:
205
205
  """Create a custom code deployment in the DataCloud."""
206
- # invokeOptions only binds at v67.0; route there when it is set so the
207
- # option isn't silently dropped. Everything else stays on v63.0.
208
- code_custom_code_path = (
209
- DATA_CUSTOM_CODE_INVOKE_OPTIONS_PATH
210
- if metadata.invokeOptions
211
- else DATA_CUSTOM_CODE_PATH
212
- )
213
- url = _join_strip_url(access_token.instance_url, code_custom_code_path)
206
+ url = _join_strip_url(access_token.instance_url, DATA_CUSTOM_CODE_PATH)
214
207
  body = dict[str, Any](
215
208
  {
216
209
  "label": metadata.name,
@@ -223,8 +216,6 @@ def create_deployment(
223
216
  )
224
217
  if metadata.functionInvokeOptions:
225
218
  body["functionInvokeOptions"] = metadata.functionInvokeOptions
226
- if metadata.invokeOptions:
227
- body["invokeOptions"] = metadata.invokeOptions
228
219
  logger.debug(f"Creating deployment {metadata.name}...")
229
220
  try:
230
221
  response = _make_api_call(
@@ -248,6 +239,8 @@ DEPENDENCIES_ARCHIVE_PATH = os.path.join(
248
239
  )
249
240
  PY_FILES_PATH = os.path.join("payload", "py-files")
250
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)
251
244
 
252
245
 
253
246
  def prepare_dependency_archive(
@@ -400,30 +393,6 @@ class DataTransformConfig(BaseConfig):
400
393
  dataspace: str
401
394
  permissions: Permissions
402
395
  dataObjects: Optional[list[DataObject]] = None
403
- streamingSource: Optional[StreamingSource] = None
404
-
405
- @property
406
- def is_streaming(self) -> bool:
407
- return self.streamingSource is not None
408
-
409
- @model_validator(mode="after")
410
- def _validate_layers(self) -> "DataTransformConfig":
411
- read_is_dlo = isinstance(self.permissions.read, DloPermission)
412
- write_is_dlo = isinstance(self.permissions.write, DloPermission)
413
- if self.is_streaming:
414
- if not write_is_dlo:
415
- raise ValueError(
416
- "A streaming transform must write to a DLO "
417
- "(permissions.write must be a 'dlo' entry)."
418
- )
419
- elif read_is_dlo != write_is_dlo:
420
- raise ValueError(
421
- "permissions.read and permissions.write must both reference "
422
- "DLOs or both reference DMOs (got "
423
- f"read={type(self.permissions.read).__name__}, "
424
- f"write={type(self.permissions.write).__name__})"
425
- )
426
- return self
427
396
 
428
397
 
429
398
  class FunctionConfig(BaseConfig):
@@ -438,15 +407,23 @@ class DmoPermission(BaseModel):
438
407
  dmo: list[str]
439
408
 
440
409
 
441
- class StreamingSource(BaseModel):
442
- type: str
443
- name: str
444
-
445
-
446
410
  class Permissions(BaseModel):
447
411
  read: Union[DloPermission, DmoPermission]
448
412
  write: Union[DloPermission, DmoPermission]
449
413
 
414
+ @model_validator(mode="after")
415
+ def _no_mixed_layers(self) -> "Permissions":
416
+ read_is_dlo = isinstance(self.read, DloPermission)
417
+ write_is_dlo = isinstance(self.write, DloPermission)
418
+ if read_is_dlo != write_is_dlo:
419
+ raise ValueError(
420
+ "permissions.read and permissions.write must both reference "
421
+ "DLOs or both reference DMOs (got "
422
+ f"read={type(self.read).__name__}, "
423
+ f"write={type(self.write).__name__})"
424
+ )
425
+ return self
426
+
450
427
 
451
428
  def _permission_entries(perm: Union[DloPermission, DmoPermission]) -> list[str]:
452
429
  """Return the list of object names regardless of layer (DLO or DMO)."""
@@ -518,9 +495,7 @@ def create_data_transform(
518
495
  ) -> dict:
519
496
  """Create a data transform in the DataCloud."""
520
497
  script_name = metadata.name
521
- # Deep copy: the template's nested nodes/sources/macros dicts would
522
- # otherwise be shared across calls and accumulate entries between deploys.
523
- request_hydrated = copy.deepcopy(DATA_TRANSFORM_REQUEST_TEMPLATE)
498
+ request_hydrated = DATA_TRANSFORM_REQUEST_TEMPLATE.copy()
524
499
 
525
500
  # Add nodes for each write entry (DLO or DMO)
526
501
  for i, name in enumerate(
@@ -563,7 +538,7 @@ def create_data_transform(
563
538
  "definition": definition,
564
539
  "label": f"{metadata.name}",
565
540
  "name": f"{metadata.name}",
566
- "type": "STREAMING" if data_transform_config.is_streaming else "BATCH",
541
+ "type": "BATCH",
567
542
  "dataSpaceName": data_transform_config.dataspace,
568
543
  }
569
544
 
@@ -628,9 +603,9 @@ def zip(
628
603
 
629
604
  with zipfile.ZipFile(ZIP_FILE_NAME, "w", zipfile.ZIP_DEFLATED) as zipf:
630
605
  for root, dirs, files in os.walk(directory):
631
- # Skip .DS_Store files when adding to zip
606
+ # Skip .DS_Store and local credentials.
632
607
  for file in files:
633
- if file != ".DS_Store":
608
+ if file not in EXCLUDED_FILES:
634
609
  abs_path = os.path.join(root, file)
635
610
  arcname = os.path.relpath(abs_path, directory)
636
611
  zipf.write(abs_path, arcname)
@@ -646,18 +621,9 @@ def deploy_full(
646
621
  callback=None,
647
622
  ) -> AccessTokenResponse:
648
623
  """Deploy a data transform in the DataCloud."""
649
- from datacustomcode.constants import SCRIPT_USE_IN_FEATURE_STREAMING
650
-
651
624
  # prepare payload
652
625
  config = get_config(directory)
653
626
 
654
- if (
655
- isinstance(config, DataTransformConfig)
656
- and config.is_streaming
657
- and not metadata.invokeOptions
658
- ):
659
- metadata.invokeOptions = [SCRIPT_USE_IN_FEATURE_STREAMING]
660
-
661
627
  # create deployment and upload payload
662
628
  deployment = create_deployment(access_token, metadata)
663
629
  zip(directory, docker_network, metadata.codeType)
@@ -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
@@ -41,45 +41,3 @@ class BaseDataCloudReader(BaseDataAccessLayer):
41
41
  name: str,
42
42
  schema: Union[AtomicType, StructType, str, None] = None,
43
43
  ) -> PySparkDataFrame: ...
44
-
45
- def read_dlo_deltas(self) -> PySparkDataFrame:
46
- """Read the streaming change feed (deltas) for a Data Lake Object.
47
-
48
- This is the streaming counterpart to :meth:`read_dlo`. It returns a
49
- streaming DataFrame over the change feed the Data Cloud runtime
50
- publishes for a streaming (``DELTA_SYNC``) transform. Concrete
51
- streaming behavior is provided by the deployed Data Cloud runtime; the
52
- base implementation raises :class:`NotImplementedError` so local
53
- readers that do not support streaming fail clearly.
54
-
55
- Returns:
56
- A streaming PySpark DataFrame over the DLO change feed.
57
-
58
- Raises:
59
- NotImplementedError: If the active reader does not support streaming
60
- deltas (e.g. the local development readers).
61
- """
62
- raise NotImplementedError(
63
- "read_dlo_deltas is only supported when running in the Data Cloud "
64
- "streaming runtime; the local reader does not support streaming "
65
- "deltas."
66
- )
67
-
68
- def read_dmo_deltas(self) -> PySparkDataFrame:
69
- """Read the streaming change feed (deltas) for a Data Model Object.
70
-
71
- Streaming counterpart to :meth:`read_dmo`. See :meth:`read_dlo_deltas`
72
- for behavior and the local-development caveat.
73
-
74
- Returns:
75
- A streaming PySpark DataFrame over the DMO change feed.
76
-
77
- Raises:
78
- NotImplementedError: If the active reader does not support streaming
79
- deltas (e.g. the local development readers).
80
- """
81
- raise NotImplementedError(
82
- "read_dmo_deltas is only supported when running in the Data Cloud "
83
- "streaming runtime; the local reader does not support streaming "
84
- "deltas."
85
- )
@@ -22,7 +22,6 @@ from datacustomcode.io.base import BaseDataAccessLayer
22
22
 
23
23
  if TYPE_CHECKING:
24
24
  from pyspark.sql import DataFrame as PySparkDataFrame, SparkSession
25
- from pyspark.sql.streaming import StreamingQuery
26
25
 
27
26
 
28
27
  class WriteMode(str, Enum):
@@ -58,35 +57,3 @@ class BaseDataCloudWriter(BaseDataAccessLayer):
58
57
  def write_to_dmo(
59
58
  self, name: str, dataframe: PySparkDataFrame, write_mode: WriteMode
60
59
  ) -> None: ...
61
-
62
- def write_dlo_deltas(
63
- self, name: str, dataframe: PySparkDataFrame
64
- ) -> StreamingQuery:
65
- """Write a streaming DataFrame of deltas to a Data Lake Object.
66
-
67
- Streaming counterpart to :meth:`write_to_dlo`. Starts a streaming query
68
- that writes each micro-batch to the target DLO via the Data Cloud
69
- streaming sink and returns the resulting ``StreamingQuery`` handle. The
70
- runtime owns the trigger and checkpoint location; callers pass only the
71
- table name. Concrete streaming behavior is provided by the deployed
72
- Data Cloud runtime; the base implementation raises
73
- :class:`NotImplementedError`.
74
-
75
- Args:
76
- name: Target Data Lake Object name.
77
- dataframe: Streaming PySpark DataFrame produced from a
78
- ``read_dlo_deltas`` / ``read_dmo_deltas`` source.
79
-
80
- Returns:
81
- The started ``StreamingQuery``; the caller drives its lifecycle
82
- (typically ``query.awaitTermination()``).
83
-
84
- Raises:
85
- NotImplementedError: If the active writer does not support streaming
86
- deltas (e.g. the local development writers).
87
- """
88
- raise NotImplementedError(
89
- "write_dlo_deltas is only supported when running in the Data Cloud "
90
- "streaming runtime; the local writer does not support streaming "
91
- "deltas."
92
- )
@@ -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
@@ -0,0 +1,121 @@
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
+ """Load developer credentials for direct callouts from ``external_callout_config.json``.
16
+
17
+ The file lives in the parent of the payload folder so it is never packaged in
18
+ the deployment zip. Its ``credentials`` section is keyed by callout reference
19
+ (``callout:<NamedCredential>``); each value carries a mandatory ``auth_type``
20
+ and an optional ``target_url``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from enum import Enum
26
+ import json
27
+ import os
28
+ from pathlib import Path
29
+ from typing import (
30
+ Any,
31
+ Dict,
32
+ Optional,
33
+ )
34
+
35
+ # Default file name; discovered in the parent of the payload folder.
36
+ EXTERNAL_CALLOUT_CREDENTIAL = "external_callout_config.json"
37
+ # Absolute-path override, primarily for tests and non-standard layouts.
38
+ CREDENTIAL_FILE_ENV_VAR = "DATACUSTOMCODE_EXTERNAL_CALLOUT_CONFIG"
39
+
40
+
41
+ class AuthType(str, Enum):
42
+ """External Credential auth types supported by External Services."""
43
+
44
+ BASIC = "Basic"
45
+ CUSTOM = "Custom"
46
+ JWT = "Jwt"
47
+ OAUTH = "OAuth"
48
+
49
+
50
+ class CredentialError(RuntimeError):
51
+ """Raised when credentials cannot be found or are invalid."""
52
+
53
+
54
+ def _discover_credential_file() -> Optional[Path]:
55
+ """Find the config file via env override, then by walking up from cwd."""
56
+ override = os.environ.get(CREDENTIAL_FILE_ENV_VAR)
57
+ if override:
58
+ return Path(override)
59
+
60
+ for directory in (Path.cwd(), *Path.cwd().parents):
61
+ candidate = directory / EXTERNAL_CALLOUT_CREDENTIAL
62
+ if candidate.is_file():
63
+ return candidate
64
+ return None
65
+
66
+
67
+ class CredentialStore:
68
+ """Reads ``external_callout_config.json`` and returns per-callout config."""
69
+
70
+ def __init__(self, credential_file: Optional[str] = None) -> None:
71
+ self._explicit_path = Path(credential_file) if credential_file else None
72
+ self._credentials: Optional[Dict[str, Dict[str, Any]]] = None
73
+
74
+ def _load(self) -> Dict[str, Dict[str, Any]]:
75
+ if self._credentials is not None:
76
+ return self._credentials
77
+
78
+ path = self._explicit_path or _discover_credential_file()
79
+ if path is None or not path.is_file():
80
+ raise CredentialError(
81
+ f"Could not find '{EXTERNAL_CALLOUT_CREDENTIAL}'. Place it in the "
82
+ f"parent of your payload folder, or set "
83
+ f"${CREDENTIAL_FILE_ENV_VAR} to its path."
84
+ )
85
+ try:
86
+ with open(path, "r") as f:
87
+ data = json.load(f)
88
+ except (OSError, json.JSONDecodeError) as exc:
89
+ raise CredentialError(f"Failed to read '{path}': {exc}") from exc
90
+
91
+ # Per-callout entries live under the ``credentials`` section, leaving
92
+ # room for other config sections alongside them in the future.
93
+ credentials = data.get("credentials") if isinstance(data, dict) else None
94
+ if not isinstance(credentials, dict):
95
+ raise CredentialError(
96
+ f"'{path}' must be a JSON object with a 'credentials' section "
97
+ f"keyed by callout reference."
98
+ )
99
+ self._credentials = credentials
100
+ return credentials
101
+
102
+ def get(self, callout_key: str) -> Dict[str, Any]:
103
+ """Return the config for a callout key (e.g. ``callout:AWS_S3_Service``).
104
+
105
+ Raises:
106
+ CredentialError: if the key is missing or has no ``auth_type``.
107
+ """
108
+ credentials = self._load()
109
+ config = credentials.get(callout_key)
110
+ if config is None:
111
+ raise CredentialError(
112
+ f"No credential configuration found for '{callout_key}'. "
113
+ f"Add it to the 'credentials' section of "
114
+ f"'{EXTERNAL_CALLOUT_CREDENTIAL}'."
115
+ )
116
+ if not isinstance(config, dict) or not config.get("auth_type"):
117
+ raise CredentialError(
118
+ f"Credential for '{callout_key}' is missing the mandatory "
119
+ f"'auth_type' field."
120
+ )
121
+ return config