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.
- datacustomcode/__init__.py +20 -0
- datacustomcode/client.py +325 -98
- datacustomcode/config.py +5 -0
- datacustomcode/config.yaml +3 -0
- datacustomcode/einstein_predictions/__init__.py +8 -0
- datacustomcode/einstein_predictions/errors.py +36 -0
- datacustomcode/einstein_predictions/spark_base.py +72 -0
- datacustomcode/einstein_predictions/spark_default.py +239 -0
- datacustomcode/einstein_predictions_config.py +45 -1
- datacustomcode/io/reader/base.py +42 -0
- datacustomcode/io/writer/base.py +33 -0
- datacustomcode/run.py +11 -0
- datacustomcode/templates/script/examples/streaming_deltas/entrypoint.py +49 -0
- datacustomcode/templates/script/payload/entrypoint.py +17 -0
- {salesforce_data_customcode-6.0.5.dev1.dist-info → salesforce_data_customcode-6.0.6.dev2.dist-info}/METADATA +96 -8
- {salesforce_data_customcode-6.0.5.dev1.dist-info → salesforce_data_customcode-6.0.6.dev2.dist-info}/RECORD +19 -15
- {salesforce_data_customcode-6.0.5.dev1.dist-info → salesforce_data_customcode-6.0.6.dev2.dist-info}/WHEEL +0 -0
- {salesforce_data_customcode-6.0.5.dev1.dist-info → salesforce_data_customcode-6.0.6.dev2.dist-info}/entry_points.txt +0 -0
- {salesforce_data_customcode-6.0.5.dev1.dist-info → salesforce_data_customcode-6.0.6.dev2.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -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
|
|
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())
|
datacustomcode/io/reader/base.py
CHANGED
|
@@ -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
|
+
)
|
datacustomcode/io/writer/base.py
CHANGED
|
@@ -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
|