Skip to content

Commit d06cb64

Browse files
authored
Merge pull request #141 from diksha-sf/main
Add support for external-callout
2 parents f677b33 + 2c9e319 commit d06cb64

36 files changed

Lines changed: 3022 additions & 3 deletions

‎src/datacustomcode/client.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from datacustomcode.file.path.default import DefaultFindFilePath
3333
from datacustomcode.io.reader.base import BaseDataCloudReader
3434
from datacustomcode.llm_gateway_config import spark_llm_gateway_config
35+
from datacustomcode.named_credential_config import spark_named_credential_config
3536
from datacustomcode.spark.default import DefaultSparkSessionProvider
3637

3738
if TYPE_CHECKING:
@@ -49,6 +50,9 @@
4950
from datacustomcode.io.reader.base import BaseDataCloudReader
5051
from datacustomcode.io.writer.base import BaseDataCloudWriter, WriteMode
5152
from datacustomcode.llm_gateway.spark_base import SparkLLMGateway
53+
from datacustomcode.named_credential.spark_base import SparkNamedCredential
54+
from datacustomcode.named_credential.types.http_request import HTTPRequest
55+
from datacustomcode.named_credential.types.http_response import HTTPResponse
5256
from datacustomcode.spark.base import BaseSparkSessionProvider
5357

5458

@@ -160,6 +164,21 @@ def _build_spark_einstein_predictions() -> "SparkEinsteinPredictions":
160164
return cfg.to_object()
161165

162166

167+
def _build_spark_named_credential() -> "SparkNamedCredential":
168+
"""Instantiate the SDK-configured :class:`SparkNamedCredential`.
169+
170+
Raises:
171+
RuntimeError: If no ``spark_named_credential_config`` has been loaded.
172+
"""
173+
cfg = spark_named_credential_config.spark_named_credential_config
174+
if cfg is None:
175+
raise RuntimeError(
176+
"spark_named_credential_config is not configured. Add a "
177+
"'spark_named_credential_config' section to config.yaml."
178+
)
179+
return cfg.to_object()
180+
181+
163182
def einstein_predict_col(
164183
model_api_name: str,
165184
prediction_type: "PredictionType",
@@ -209,6 +228,40 @@ def einstein_predict_col(
209228
)
210229

211230

231+
def named_credential_request_col(
232+
request: "HTTPRequest",
233+
body: Optional["Column"] = None,
234+
) -> "Column":
235+
"""Build a Spark Column that makes one Named Credential callout per row.
236+
237+
The endpoint, method, and headers are fixed for the call (taken from
238+
``request``); only ``body`` varies per row. Use this instead of
239+
:meth:`Client.named_credential_request` when the callout runs across a
240+
DataFrame so each row is dispatched independently rather than one-shot on
241+
the driver.
242+
243+
The returned Column yields a struct ``{status, response, error_code,
244+
error_message}`` for each row. ``response`` is itself a struct
245+
``{status_code, body, headers}``. Use ``[...]`` to pick a field, e.g.
246+
``named_credential_request_col(...)["response"]["status_code"]``. A transport
247+
failure sets ``status`` to ``ERROR`` and populates ``error_message`` (a non-2xx
248+
HTTP response is still ``SUCCESS`` with its code in ``response.status_code``),
249+
so a single bad row does not abort the whole Spark job.
250+
251+
Args:
252+
request: The callout template — its symbolic reference, method, and
253+
headers are applied to every row.
254+
body: Optional per-row ``Column`` holding the request body as a
255+
string (or null for no body).
256+
257+
Returns:
258+
A Spark ``Column`` of ``StructType`` with fields ``status``,
259+
``response``, ``error_code``, and ``error_message``.
260+
"""
261+
named_credential = Client()._get_spark_named_credential()
262+
return named_credential.request_col(request, body=body)
263+
264+
212265
class DataCloudObjectType(Enum):
213266
DLO = "dlo"
214267
DMO = "dmo"
@@ -266,6 +319,14 @@ class _BaseClient:
266319
spark_llm_gateway: Optional custom :class:`SparkLLMGateway`.
267320
spark_einstein_predictions: Optional custom
268321
:class:`SparkEinsteinPredictions`.
322+
spark_named_credential: Optional custom :class:`SparkNamedCredential`.
323+
324+
Example:
325+
>>> client = Client()
326+
>>> file_path = client.find_file_path("data.csv")
327+
>>> dlo = client.read_dlo("my_dlo")
328+
>>> client.write_to_dmo("my_dmo", dlo)
329+
>>> answer = client.llm_gateway_generate_text("Generate a greeting message")
269330
"""
270331

271332
# Each concrete subclass gets its own ``_instance`` slot: reads fall through
@@ -285,6 +346,7 @@ class _BaseClient:
285346
_file: DefaultFindFilePath
286347
_spark_llm_gateway: Optional[SparkLLMGateway]
287348
_spark_einstein_predictions: Optional[SparkEinsteinPredictions]
349+
_spark_named_credential: Optional[SparkNamedCredential]
288350
_data_layer_history: dict[DataCloudObjectType, set[str]]
289351
_code_type: str
290352

@@ -295,13 +357,15 @@ def __new__(
295357
spark_provider: Optional[BaseSparkSessionProvider] = None,
296358
spark_llm_gateway: Optional[SparkLLMGateway] = None,
297359
spark_einstein_predictions: Optional[SparkEinsteinPredictions] = None,
360+
spark_named_credential: Optional[SparkNamedCredential] = None,
298361
code_type: str = "script",
299362
) -> _ClientT:
300363

301364
if cls._instance is None:
302365
instance = super().__new__(cls)
303366
instance._spark_llm_gateway = spark_llm_gateway
304367
instance._spark_einstein_predictions = spark_einstein_predictions
368+
instance._spark_named_credential = spark_named_credential
305369
# Initialize Readers and Writers from config
306370
# and/or provided reader and writer
307371
if reader is None or writer is None:
@@ -474,6 +538,41 @@ def _get_spark_einstein_predictions(self) -> SparkEinsteinPredictions:
474538
self._spark_einstein_predictions = _build_spark_einstein_predictions()
475539
return self._spark_einstein_predictions
476540

541+
def named_credential_request(
542+
self,
543+
request: "HTTPRequest",
544+
body: Optional[str] = None,
545+
) -> "HTTPResponse":
546+
"""Issue a one-shot Named Credential external callout. This is the
547+
scalar counterpart to :func:`named_credential_request_col`: it runs
548+
**once** on the driver — not per row. Use the column helper method
549+
instead when you want to fan a callout out across every row of a
550+
DataFrame.
551+
552+
Example:
553+
554+
>>> from datacustomcode.named_credential.types.http_request_builder \\
555+
... import HTTPRequestBuilder
556+
>>> request = (
557+
... HTTPRequestBuilder().set_url("callout:NC/search").build()
558+
... )
559+
>>> response = Client().named_credential_request(request)
560+
561+
Args:
562+
request: The callout request
563+
body: Optional request body. Set the ``Content-Type`` header to
564+
match the format; the SDK does not assume or inject one.
565+
566+
Returns:
567+
The external service's response.
568+
"""
569+
return self._get_spark_named_credential().request(request, body=body)
570+
571+
def _get_spark_named_credential(self) -> SparkNamedCredential:
572+
if self._spark_named_credential is None:
573+
self._spark_named_credential = _build_spark_named_credential()
574+
return self._spark_named_credential
575+
477576
def _validate_data_layer_history_does_not_contain(
478577
self, data_cloud_object_type: DataCloudObjectType
479578
) -> None:

‎src/datacustomcode/config.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ llm_gateway_config:
3434

3535
spark_llm_gateway_config:
3636
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

‎src/datacustomcode/deploy.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838

3939
from datacustomcode.cmd import cmd_output
4040
from datacustomcode.constants import REQUEST_TYPE_TO_FEATURE
41+
from datacustomcode.named_credential.direct.credentials import (
42+
EXTERNAL_CALLOUT_CREDENTIAL,
43+
)
4144
from datacustomcode.scan import find_base_directory, get_package_type
4245

4346
DATA_CUSTOM_CODE_PATH = "services/data/v63.0/ssot/data-custom-code"
@@ -248,6 +251,8 @@ def create_deployment(
248251
)
249252
PY_FILES_PATH = os.path.join("payload", "py-files")
250253
ZIP_FILE_NAME = "deployment.zip"
254+
# Local-only files that must never be packaged into the deployment zip.
255+
EXCLUDED_FILES = (".DS_Store", EXTERNAL_CALLOUT_CREDENTIAL)
251256

252257

253258
def prepare_dependency_archive(
@@ -628,9 +633,9 @@ def zip(
628633

629634
with zipfile.ZipFile(ZIP_FILE_NAME, "w", zipfile.ZIP_DEFLATED) as zipf:
630635
for root, dirs, files in os.walk(directory):
631-
# Skip .DS_Store files when adding to zip
636+
# Skip .DS_Store and local credentials.
632637
for file in files:
633-
if file != ".DS_Store":
638+
if file not in EXCLUDED_FILES:
634639
abs_path = os.path.join(root, file)
635640
arcname = os.path.relpath(abs_path, directory)
636641
zipf.write(abs_path, arcname)

‎src/datacustomcode/function/runtime.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from datacustomcode.function.base import BaseRuntime
2424
from datacustomcode.llm_gateway.base import LLMGateway
2525
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
2628

2729

2830
class Runtime(BaseRuntime):
@@ -69,6 +71,7 @@ def __init__(self) -> None:
6971
self._llm_gateway: Optional[LLMGateway] = None
7072
self._file = DefaultFindFilePath()
7173
self._einstein_predictions: Optional[EinsteinPredictions] = None
74+
self._named_credential: Optional[NamedCredential] = None
7275

7376
@property
7477
def llm_gateway(self) -> LLMGateway:
@@ -98,3 +101,16 @@ def einstein_predictions(self) -> EinsteinPredictions:
98101
einstein_predictions_config.einstein_predictions_config.to_object()
99102
)
100103
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
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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.errors import NamedCredentialCallError
19+
from datacustomcode.named_credential.spark_base import SparkNamedCredential
20+
from datacustomcode.named_credential.spark_default import DefaultSparkNamedCredential
21+
22+
__all__ = [
23+
"DefaultNamedCredential",
24+
"DefaultSparkNamedCredential",
25+
"NamedCredential",
26+
"NamedCredentialCallError",
27+
"SparkNamedCredential",
28+
]
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)