salesforce-data-customcode 6.0.5.dev1__py3-none-any.whl → 6.0.6.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.
@@ -0,0 +1,72 @@
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 (
19
+ TYPE_CHECKING,
20
+ Any,
21
+ Dict,
22
+ Optional,
23
+ )
24
+
25
+ from datacustomcode.mixin import UserExtendableNamedConfigMixin
26
+
27
+ if TYPE_CHECKING:
28
+ from pyspark.sql import Column
29
+
30
+ from datacustomcode.einstein_predictions.types import PredictionType
31
+
32
+
33
+ class SparkEinsteinPredictions(ABC, UserExtendableNamedConfigMixin):
34
+ CONFIG_NAME: str
35
+
36
+ def __init__(self, **kwargs: Any) -> None:
37
+ pass
38
+
39
+ @abstractmethod
40
+ def einstein_predict(
41
+ self,
42
+ model_api_name: str,
43
+ prediction_type: PredictionType,
44
+ features: Dict[str, Any],
45
+ settings: Optional[Dict[str, Any]] = None,
46
+ ) -> Dict[str, Any]:
47
+ """Issue a one-shot Einstein prediction and return the response data.
48
+
49
+ ``features`` maps each model feature column name to a single scalar
50
+ value (``str``/``float``/``bool``). The value is wrapped into a
51
+ single-element prediction column of the appropriate type.
52
+ """
53
+
54
+ @abstractmethod
55
+ def einstein_predict_col(
56
+ self,
57
+ model_api_name: str,
58
+ prediction_type: PredictionType,
59
+ features: Dict[str, "Column"],
60
+ settings: Optional[Dict[str, Any]] = None,
61
+ ) -> "Column":
62
+ """Build a Spark ``Column`` that invokes Einstein predict per row and
63
+ yields a struct ``{status, response, error_code, error_message}``.
64
+
65
+ ``features`` maps each model feature column name to a Spark ``Column``
66
+ supplying that feature's per-row value. Select an individual field,
67
+ e.g. ``einstein_predict_col(...)["response"]``. ``response`` holds the
68
+ prediction response payload as a JSON string. Returning a struct means
69
+ a single failing row leaves the rest of the DataFrame intact — callers
70
+ can inspect ``status`` / ``error_code`` per row instead of having the
71
+ Spark job abort.
72
+ """
@@ -0,0 +1,239 @@
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
+ import json
18
+ from typing import (
19
+ TYPE_CHECKING,
20
+ Any,
21
+ Dict,
22
+ Optional,
23
+ )
24
+
25
+ from datacustomcode.einstein_predictions.spark_base import SparkEinsteinPredictions
26
+ from datacustomcode.einstein_predictions.types import (
27
+ PredictionColumn,
28
+ PredictionRequest,
29
+ PredictionType,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from pyspark.sql import Column
34
+
35
+ from datacustomcode.einstein_predictions.base import EinsteinPredictions
36
+ from datacustomcode.einstein_predictions.types import PredictionResponse
37
+
38
+
39
+ _STATUS_SUCCESS = "SUCCESS"
40
+ _STATUS_ERROR = "ERROR"
41
+
42
+
43
+ class DefaultSparkEinsteinPredictions(SparkEinsteinPredictions):
44
+
45
+ CONFIG_NAME = "DefaultSparkEinsteinPredictions"
46
+
47
+ def __init__(
48
+ self,
49
+ einstein_predictions: Optional["EinsteinPredictions"] = None,
50
+ **kwargs: Any,
51
+ ) -> None:
52
+ super().__init__(**kwargs)
53
+ if einstein_predictions is None:
54
+ einstein_predictions = _build_underlying_predictions()
55
+ self._einstein_predictions: "EinsteinPredictions" = einstein_predictions
56
+
57
+ def einstein_predict(
58
+ self,
59
+ model_api_name: str,
60
+ prediction_type: PredictionType,
61
+ features: Dict[str, Any],
62
+ settings: Optional[Dict[str, Any]] = None,
63
+ ) -> Dict[str, Any]:
64
+ return _invoke_predictions(
65
+ self._einstein_predictions,
66
+ model_api_name,
67
+ prediction_type,
68
+ features,
69
+ settings,
70
+ )
71
+
72
+ def einstein_predict_col(
73
+ self,
74
+ model_api_name: str,
75
+ prediction_type: PredictionType,
76
+ features: Dict[str, "Column"],
77
+ settings: Optional[Dict[str, Any]] = None,
78
+ ) -> "Column":
79
+ """Build a per-row UDF that returns a struct ``{status, response,
80
+ error_code, error_message}`` so per-row failures do not abort the
81
+ Spark job. Callers select the field they want, e.g.
82
+ ``einstein_predict_col(...)["response"]``. ``response`` carries the
83
+ prediction response payload serialized as a JSON string.
84
+ """
85
+ from pyspark.sql.functions import struct, udf
86
+ from pyspark.sql.types import (
87
+ StringType,
88
+ StructField,
89
+ StructType,
90
+ )
91
+
92
+ feature_names = list(features.keys())
93
+ values_col = struct(*[features[name].alias(name) for name in feature_names])
94
+
95
+ predictions = self._einstein_predictions
96
+ result_schema = StructType(
97
+ [
98
+ StructField("status", StringType(), True),
99
+ StructField("response", StringType(), True),
100
+ StructField("error_code", StringType(), True),
101
+ StructField("error_message", StringType(), True),
102
+ ]
103
+ )
104
+
105
+ def _predict(values_row: Any) -> Dict[str, Optional[str]]:
106
+ if values_row is None:
107
+ return {
108
+ "status": _STATUS_ERROR,
109
+ "response": None,
110
+ "error_code": None,
111
+ "error_message": "features column was null for this row",
112
+ }
113
+ row_features = (
114
+ values_row.asDict()
115
+ if hasattr(values_row, "asDict")
116
+ else dict(values_row)
117
+ )
118
+ return _invoke_predictions_as_struct(
119
+ predictions,
120
+ model_api_name,
121
+ prediction_type,
122
+ row_features,
123
+ settings,
124
+ )
125
+
126
+ return udf(_predict, result_schema)(values_col)
127
+
128
+
129
+ def _build_underlying_predictions() -> "EinsteinPredictions":
130
+ from datacustomcode.einstein_predictions_config import einstein_predictions_config
131
+
132
+ cfg = einstein_predictions_config.einstein_predictions_config
133
+ if cfg is None:
134
+ raise RuntimeError(
135
+ "einstein_predictions_config is not configured. Add an "
136
+ "'einstein_predictions_config' section to config.yaml."
137
+ )
138
+ return cfg.to_object()
139
+
140
+
141
+ def _feature_to_prediction_column(name: str, value: Any) -> PredictionColumn:
142
+ """Wrap a single scalar feature value into a one-element prediction column.
143
+
144
+ The value's Python type selects the prediction column value type. ``bool``
145
+ is checked before ``int``/``float`` because ``bool`` is a subclass of
146
+ ``int`` in Python.
147
+ """
148
+ if isinstance(value, bool):
149
+ return PredictionColumn(column_name=name, boolean_values=[value])
150
+ if isinstance(value, (int, float)):
151
+ return PredictionColumn(column_name=name, double_values=[float(value)])
152
+ return PredictionColumn(column_name=name, string_values=[str(value)])
153
+
154
+
155
+ def _build_request(
156
+ model_api_name: str,
157
+ prediction_type: PredictionType,
158
+ features: Dict[str, Any],
159
+ settings: Optional[Dict[str, Any]],
160
+ ) -> PredictionRequest:
161
+ prediction_columns = [
162
+ _feature_to_prediction_column(name, value) for name, value in features.items()
163
+ ]
164
+ return PredictionRequest(
165
+ prediction_type=prediction_type,
166
+ model_api_name=model_api_name,
167
+ prediction_columns=prediction_columns,
168
+ settings=settings,
169
+ )
170
+
171
+
172
+ def _call_predictions(
173
+ predictions: "EinsteinPredictions",
174
+ model_api_name: str,
175
+ prediction_type: PredictionType,
176
+ features: Dict[str, Any],
177
+ settings: Optional[Dict[str, Any]],
178
+ ) -> "PredictionResponse":
179
+ """Build the request and dispatch it to the underlying predictions resource."""
180
+ request = _build_request(model_api_name, prediction_type, features, settings)
181
+ return predictions.predict(request)
182
+
183
+
184
+ def _invoke_predictions(
185
+ predictions: "EinsteinPredictions",
186
+ model_api_name: str,
187
+ prediction_type: PredictionType,
188
+ features: Dict[str, Any],
189
+ settings: Optional[Dict[str, Any]],
190
+ ) -> Dict[str, Any]:
191
+ from datacustomcode.einstein_predictions.errors import EinsteinPredictionsCallError
192
+
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)
198
+ raise EinsteinPredictionsCallError(
199
+ f"Einstein Predictions call failed: "
200
+ f"status_code={response.status_code}, "
201
+ f"error_code={error_code!r}, message={response.data!r}",
202
+ status=response.status_code,
203
+ error_code=error_code,
204
+ error_message=str(response.data) if response.data else None,
205
+ )
206
+ return response.data or {}
207
+
208
+
209
+ def _invoke_predictions_as_struct(
210
+ predictions: "EinsteinPredictions",
211
+ model_api_name: str,
212
+ prediction_type: PredictionType,
213
+ features: Dict[str, Any],
214
+ settings: Optional[Dict[str, Any]],
215
+ ) -> Dict[str, Optional[str]]:
216
+ response = _call_predictions(
217
+ predictions, model_api_name, prediction_type, features, settings
218
+ )
219
+ if not response.is_success:
220
+ return {
221
+ "status": _STATUS_ERROR,
222
+ "response": None,
223
+ "error_code": _extract_error_code(response),
224
+ "error_message": str(response.data) if response.data else None,
225
+ }
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
+
233
+
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
@@ -21,11 +21,17 @@ from typing import (
21
21
  Union,
22
22
  )
23
23
 
24
- from datacustomcode.common_config import BaseConfig, default_config_file
24
+ from datacustomcode.common_config import (
25
+ BaseConfig,
26
+ BaseObjectConfig,
27
+ default_config_file,
28
+ )
25
29
  from datacustomcode.einstein_platform_config import CredentialsObjectConfig
26
30
  from datacustomcode.einstein_predictions.base import EinsteinPredictions
31
+ from datacustomcode.einstein_predictions.spark_base import SparkEinsteinPredictions
27
32
 
28
33
  _E = TypeVar("_E", bound=EinsteinPredictions)
34
+ _S = TypeVar("_S", bound=SparkEinsteinPredictions)
29
35
 
30
36
 
31
37
  class EinsteinPredictionsObjectConfig(CredentialsObjectConfig[_E], Generic[_E]):
@@ -54,6 +60,44 @@ class EinsteinPredictionsConfig(BaseConfig):
54
60
  return self
55
61
 
56
62
 
63
+ class SparkEinsteinPredictionsObjectConfig(BaseObjectConfig, Generic[_S]):
64
+ type_to_create: ClassVar[Type[SparkEinsteinPredictions]] = SparkEinsteinPredictions # type: ignore[type-abstract]
65
+
66
+ def to_object(self) -> SparkEinsteinPredictions:
67
+ type_ = self.type_to_create.subclass_from_config_name(self.type_config_name)
68
+ return type_(**self.options)
69
+
70
+
71
+ class SparkEinsteinPredictionsConfig(BaseConfig):
72
+ spark_einstein_predictions_config: Union[
73
+ SparkEinsteinPredictionsObjectConfig[SparkEinsteinPredictions], None
74
+ ] = None
75
+
76
+ def update(
77
+ self, other: "SparkEinsteinPredictionsConfig"
78
+ ) -> "SparkEinsteinPredictionsConfig":
79
+ def merge(
80
+ config_a: Union[SparkEinsteinPredictionsObjectConfig, None],
81
+ config_b: Union[SparkEinsteinPredictionsObjectConfig, None],
82
+ ) -> Union[SparkEinsteinPredictionsObjectConfig, None]:
83
+ if config_a is not None and config_a.force:
84
+ return config_a
85
+ if config_b:
86
+ return config_b
87
+ return config_a
88
+
89
+ self.spark_einstein_predictions_config = merge(
90
+ self.spark_einstein_predictions_config,
91
+ other.spark_einstein_predictions_config,
92
+ )
93
+ return self
94
+
95
+
57
96
  # Global Einstein Predictions config instance
58
97
  einstein_predictions_config = EinsteinPredictionsConfig()
59
98
  einstein_predictions_config.load(default_config_file())
99
+
100
+
101
+ # Global Spark Einstein Predictions config instance
102
+ spark_einstein_predictions_config = SparkEinsteinPredictionsConfig()
103
+ spark_einstein_predictions_config.load(default_config_file())
@@ -41,3 +41,45 @@ 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,6 +22,7 @@ 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
25
26
 
26
27
 
27
28
  class WriteMode(str, Enum):
@@ -57,3 +58,35 @@ class BaseDataCloudWriter(BaseDataAccessLayer):
57
58
  def write_to_dmo(
58
59
  self, name: str, dataframe: PySparkDataFrame, write_mode: WriteMode
59
60
  ) -> 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
+ )
datacustomcode/run.py CHANGED
@@ -42,6 +42,15 @@ def _set_config_option(config_obj, key: str, value: Optional[str]) -> None:
42
42
  config_obj.options[key] = value
43
43
 
44
44
 
45
+ def _read_streaming_source(config_json: dict) -> Optional[str]:
46
+ """Return the streaming source name from config.json's ``streamingSource``."""
47
+ source = config_json.get("streamingSource")
48
+ if not isinstance(source, dict):
49
+ return None
50
+ name = source.get("name")
51
+ return str(name) if name else None
52
+
53
+
45
54
  def _update_config_options(profile: Optional[str], sf_cli_org: Optional[str]):
46
55
  if sf_cli_org:
47
56
  config_key = "sf_cli_org"
@@ -125,6 +134,8 @@ def run_entrypoint(
125
134
  _set_config_option(config.reader_config, "dataspace", dataspace)
126
135
  _set_config_option(config.writer_config, "dataspace", dataspace)
127
136
 
137
+ config.streaming_source = _read_streaming_source(config_json)
138
+
128
139
  _update_config_options(profile, sf_cli_org)
129
140
 
130
141
  for dependency in dependencies:
@@ -0,0 +1,49 @@
1
+ """Streaming BYOC transform: read a DLO change feed and write the deltas back.
2
+
3
+ This example is the streaming counterpart to a normal batch entrypoint. Instead
4
+ of a batch ``Client`` with ``read_dlo`` / ``write_to_dlo`` (which read and write
5
+ a bounded snapshot), it uses a :class:`StreamingClient` and its streaming delta
6
+ methods:
7
+
8
+ * ``client.read_dlo_deltas()`` returns a *streaming* DataFrame over the
9
+ Change Data Feed of the source DLO. Each row carries the source columns plus
10
+ change-feed metadata columns (``_record_type``, ``_commit_*``).
11
+ * ``client.write_dlo_deltas(name, df)`` starts a streaming query that writes
12
+ each micro-batch to the target DLO and returns the ``StreamingQuery`` handle.
13
+ The runtime owns the trigger, and checkpoint location — the caller only
14
+ chooses the table.
15
+
16
+ The transform in between is ordinary PySpark. Because the source is a change
17
+ feed, keep the metadata columns on the DataFrame you hand to
18
+ ``write_dlo_deltas`` — the sink relies on them to merge changes correctly.
19
+
20
+ This entrypoint only runs inside the Data Cloud streaming (``DELTA_SYNC``)
21
+ runtime; the local ``datacustomcode run`` readers/writers raise
22
+ ``NotImplementedError`` for the delta methods.
23
+ """
24
+
25
+ from pyspark.sql.functions import col, upper
26
+
27
+ from datacustomcode.client import StreamingClient
28
+
29
+
30
+ def main():
31
+ client = StreamingClient()
32
+
33
+ # Streaming DataFrame over the source DLO's change feed.
34
+ deltas = client.read_dlo_deltas()
35
+
36
+ # Ordinary PySpark transform.
37
+ transformed = deltas.withColumn("description__c", upper(col("description__c")))
38
+
39
+ # Start the streaming write. write_dlo_deltas returns the StreamingQuery;
40
+ # the trigger and checkpoint location are provided by the runtime.
41
+ query = client.write_dlo_deltas("Account_std_copy__dll", transformed)
42
+
43
+ # Drive the query's lifecycle. In the streaming runtime this blocks until
44
+ # the job is stopped by the platform.
45
+ query.awaitTermination()
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -38,6 +38,23 @@ def main():
38
38
  >>> generated_text = client.llm_gateway_generate_text(
39
39
  ... prompt, model_id
40
40
  ... )
41
+
42
+ You can also score rows with an Einstein Studio model. The per-row helper
43
+ returns the same ``{status, response, error_code, error_message}`` struct,
44
+ where ``response`` is the prediction payload as a JSON string.
45
+
46
+ Example:
47
+
48
+ >>> from datacustomcode.client import einstein_predict_col
49
+ >>> from datacustomcode.einstein_predictions.types import PredictionType
50
+ df_scored = df.withColumn(
51
+ ... "prediction__c",
52
+ ... einstein_predict_col(
53
+ ... "my_regression_model",
54
+ ... PredictionType.REGRESSION,
55
+ ... {"beds": col("beds__c"), "baths": col("baths__c")},
56
+ ... )["response"],
57
+ ... )
41
58
  """
42
59
 
43
60
  # Drop specific columns related to relationships