salesforce-data-customcode 6.0.7.dev1__py3-none-any.whl → 6.1.0.dev1__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 +5 -0
- datacustomcode/cli.py +29 -4
- datacustomcode/client.py +211 -100
- datacustomcode/config.py +5 -0
- datacustomcode/constants.py +8 -0
- datacustomcode/deploy.py +55 -16
- datacustomcode/einstein_predictions/spark_default.py +88 -27
- datacustomcode/io/reader/base.py +42 -0
- datacustomcode/io/writer/base.py +33 -0
- datacustomcode/run.py +11 -0
- datacustomcode/scan.py +164 -29
- datacustomcode/template.py +13 -1
- datacustomcode/templates/script/examples/streaming_deltas/entrypoint.py +49 -0
- {salesforce_data_customcode-6.0.7.dev1.dist-info → salesforce_data_customcode-6.1.0.dev1.dist-info}/METADATA +40 -3
- {salesforce_data_customcode-6.0.7.dev1.dist-info → salesforce_data_customcode-6.1.0.dev1.dist-info}/RECORD +18 -17
- {salesforce_data_customcode-6.0.7.dev1.dist-info → salesforce_data_customcode-6.1.0.dev1.dist-info}/WHEEL +0 -0
- {salesforce_data_customcode-6.0.7.dev1.dist-info → salesforce_data_customcode-6.1.0.dev1.dist-info}/entry_points.txt +0 -0
- {salesforce_data_customcode-6.0.7.dev1.dist-info → salesforce_data_customcode-6.1.0.dev1.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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=
|
|
204
|
-
error_message=
|
|
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
|
-
|
|
217
|
-
|
|
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":
|
|
224
|
-
"error_message":
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
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:
|
datacustomcode/scan.py
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
import ast
|
|
18
|
+
import copy
|
|
18
19
|
import json
|
|
19
20
|
import os
|
|
20
21
|
import sys
|
|
@@ -32,6 +33,8 @@ import pydantic
|
|
|
32
33
|
from datacustomcode.version import get_version
|
|
33
34
|
|
|
34
35
|
DATA_ACCESS_METHODS = ["read_dlo", "read_dmo", "write_to_dlo", "write_to_dmo"]
|
|
36
|
+
STREAMING_READ_METHODS = ["read_dlo_deltas", "read_dmo_deltas"]
|
|
37
|
+
STREAMING_WRITE_METHODS = ["write_dlo_deltas"]
|
|
35
38
|
|
|
36
39
|
DATA_TRANSFORM_CONFIG_TEMPLATE = {
|
|
37
40
|
"sdkVersion": get_version(),
|
|
@@ -43,6 +46,20 @@ DATA_TRANSFORM_CONFIG_TEMPLATE = {
|
|
|
43
46
|
},
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
STREAMING_TRANSFORM_CONFIG_TEMPLATE = {
|
|
50
|
+
"sdkVersion": get_version(),
|
|
51
|
+
"entryPoint": "",
|
|
52
|
+
"dataspace": "default",
|
|
53
|
+
"streamingSource": {
|
|
54
|
+
"type": "dlo",
|
|
55
|
+
"name": "",
|
|
56
|
+
},
|
|
57
|
+
"permissions": {
|
|
58
|
+
"read": {},
|
|
59
|
+
"write": {},
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
FUNCTION_CONFIG_TEMPLATE = {
|
|
47
64
|
"entryPoint": "",
|
|
48
65
|
}
|
|
@@ -160,6 +177,35 @@ class DataAccessLayerCalls(pydantic.BaseModel):
|
|
|
160
177
|
return next(iter(self.write_to_dmo))
|
|
161
178
|
|
|
162
179
|
|
|
180
|
+
class StreamingDataAccessLayerCalls(pydantic.BaseModel):
|
|
181
|
+
read_dlo_deltas: bool
|
|
182
|
+
read_dmo_deltas: bool
|
|
183
|
+
write_dlo_deltas: frozenset[str]
|
|
184
|
+
|
|
185
|
+
@pydantic.model_validator(mode="after")
|
|
186
|
+
def validate_access_layer(self) -> StreamingDataAccessLayerCalls:
|
|
187
|
+
if self.read_dlo_deltas and self.read_dmo_deltas:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
"Cannot read DLO and DMO deltas in the same streaming transform."
|
|
190
|
+
)
|
|
191
|
+
if not self.read_dlo_deltas and not self.read_dmo_deltas:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
"A streaming transform must read from at least one DLO or DMO "
|
|
194
|
+
"delta stream (read_dlo_deltas / read_dmo_deltas)."
|
|
195
|
+
)
|
|
196
|
+
if not self.write_dlo_deltas:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"A streaming transform must write to at least one DLO via "
|
|
199
|
+
"write_dlo_deltas."
|
|
200
|
+
)
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def read_layer(self) -> str:
|
|
205
|
+
"""Return the read source layer, ``"dlo"`` or ``"dmo"``."""
|
|
206
|
+
return "dlo" if self.read_dlo_deltas else "dmo"
|
|
207
|
+
|
|
208
|
+
|
|
163
209
|
class ClientMethodVisitor(ast.NodeVisitor):
|
|
164
210
|
"""AST Visitor that finds all instances of Client read/write method calls."""
|
|
165
211
|
|
|
@@ -168,6 +214,9 @@ class ClientMethodVisitor(ast.NodeVisitor):
|
|
|
168
214
|
self._read_dmo_instances: set[str] = set()
|
|
169
215
|
self._write_to_dlo_instances: set[str] = set()
|
|
170
216
|
self._write_to_dmo_instances: set[str] = set()
|
|
217
|
+
self._read_dlo_deltas: bool = False
|
|
218
|
+
self._read_dmo_deltas: bool = False
|
|
219
|
+
self._write_dlo_deltas_instances: set[str] = set()
|
|
171
220
|
self.variable_values: Dict[str, Union[str, None]] = {}
|
|
172
221
|
|
|
173
222
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
@@ -189,14 +238,15 @@ class ClientMethodVisitor(ast.NodeVisitor):
|
|
|
189
238
|
node.func.value, ast.Name
|
|
190
239
|
):
|
|
191
240
|
method_name = node.func.attr
|
|
241
|
+
|
|
242
|
+
if method_name == "read_dlo_deltas":
|
|
243
|
+
self._read_dlo_deltas = True
|
|
244
|
+
elif method_name == "read_dmo_deltas":
|
|
245
|
+
self._read_dmo_deltas = True
|
|
246
|
+
|
|
192
247
|
if method_name in DATA_ACCESS_METHODS and node.args:
|
|
193
248
|
arg = node.args[0]
|
|
194
|
-
name =
|
|
195
|
-
|
|
196
|
-
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
197
|
-
name = arg.value
|
|
198
|
-
elif isinstance(arg, ast.Name) and arg.id in self.variable_values:
|
|
199
|
-
name = self.variable_values[arg.id]
|
|
249
|
+
name = self._resolve_name_arg(arg)
|
|
200
250
|
|
|
201
251
|
if name:
|
|
202
252
|
if method_name == "read_dlo":
|
|
@@ -207,8 +257,29 @@ class ClientMethodVisitor(ast.NodeVisitor):
|
|
|
207
257
|
self._write_to_dlo_instances.add(name)
|
|
208
258
|
elif method_name == "write_to_dmo":
|
|
209
259
|
self._write_to_dmo_instances.add(name)
|
|
260
|
+
elif method_name in STREAMING_WRITE_METHODS and node.args:
|
|
261
|
+
name = self._resolve_name_arg(node.args[0])
|
|
262
|
+
if name and method_name == "write_dlo_deltas":
|
|
263
|
+
self._write_dlo_deltas_instances.add(name)
|
|
210
264
|
self.generic_visit(node)
|
|
211
265
|
|
|
266
|
+
def _resolve_name_arg(self, arg: ast.expr) -> Union[str, None]:
|
|
267
|
+
"""Resolve a string-literal or tracked-variable first argument."""
|
|
268
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
269
|
+
return arg.value
|
|
270
|
+
if isinstance(arg, ast.Name) and arg.id in self.variable_values:
|
|
271
|
+
return self.variable_values[arg.id]
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def is_streaming(self) -> bool:
|
|
276
|
+
"""Whether any streaming (delta) access method was found."""
|
|
277
|
+
return (
|
|
278
|
+
self._read_dlo_deltas
|
|
279
|
+
or self._read_dmo_deltas
|
|
280
|
+
or bool(self._write_dlo_deltas_instances)
|
|
281
|
+
)
|
|
282
|
+
|
|
212
283
|
def found(self) -> DataAccessLayerCalls:
|
|
213
284
|
return DataAccessLayerCalls(
|
|
214
285
|
read_dlo=frozenset(self._read_dlo_instances),
|
|
@@ -217,6 +288,13 @@ class ClientMethodVisitor(ast.NodeVisitor):
|
|
|
217
288
|
write_to_dmo=frozenset(self._write_to_dmo_instances),
|
|
218
289
|
)
|
|
219
290
|
|
|
291
|
+
def found_streaming(self) -> StreamingDataAccessLayerCalls:
|
|
292
|
+
return StreamingDataAccessLayerCalls(
|
|
293
|
+
read_dlo_deltas=self._read_dlo_deltas,
|
|
294
|
+
read_dmo_deltas=self._read_dmo_deltas,
|
|
295
|
+
write_dlo_deltas=frozenset(self._write_dlo_deltas_instances),
|
|
296
|
+
)
|
|
297
|
+
|
|
220
298
|
|
|
221
299
|
class ImportVisitor(ast.NodeVisitor):
|
|
222
300
|
"""AST Visitor that extracts external package imports from Python code."""
|
|
@@ -301,23 +379,51 @@ def write_requirements_file(file_path: str) -> str:
|
|
|
301
379
|
return requirements_path
|
|
302
380
|
|
|
303
381
|
|
|
304
|
-
def
|
|
305
|
-
"""
|
|
382
|
+
def _visit_file(file_path: str) -> ClientMethodVisitor:
|
|
383
|
+
"""Parse a Python file and return the populated method visitor."""
|
|
306
384
|
with open(file_path, "r") as f:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
385
|
+
tree = ast.parse(f.read())
|
|
386
|
+
visitor = ClientMethodVisitor()
|
|
387
|
+
visitor.visit(tree)
|
|
388
|
+
return visitor
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def scan_file(file_path: str) -> DataAccessLayerCalls:
|
|
392
|
+
"""Scan a single Python file for batch Client read/write method calls."""
|
|
393
|
+
return _visit_file(file_path).found()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def scan_file_streaming(file_path: str) -> StreamingDataAccessLayerCalls:
|
|
397
|
+
"""Scan a single Python file for StreamingClient delta method calls."""
|
|
398
|
+
return _visit_file(file_path).found_streaming()
|
|
399
|
+
|
|
312
400
|
|
|
401
|
+
def file_is_streaming(file_path: str) -> bool:
|
|
402
|
+
"""Return whether the entrypoint uses streaming (delta) access methods."""
|
|
403
|
+
return _visit_file(file_path).is_streaming
|
|
313
404
|
|
|
314
|
-
|
|
315
|
-
|
|
405
|
+
|
|
406
|
+
def dc_config_json_from_file(
|
|
407
|
+
file_path: str, type: str, streaming: bool = False
|
|
408
|
+
) -> dict[str, Any]:
|
|
409
|
+
"""Create a Data Cloud Custom Code config JSON from a script.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
file_path: Path to the entrypoint.
|
|
413
|
+
type: Package type, ``"script"`` or ``"function"``.
|
|
414
|
+
streaming: For scripts, a streaming
|
|
415
|
+
(``streamingSource``) config instead of a batch one.
|
|
416
|
+
"""
|
|
316
417
|
config: dict[str, Any]
|
|
317
418
|
if type == "script":
|
|
318
|
-
|
|
419
|
+
template = (
|
|
420
|
+
STREAMING_TRANSFORM_CONFIG_TEMPLATE
|
|
421
|
+
if streaming
|
|
422
|
+
else DATA_TRANSFORM_CONFIG_TEMPLATE
|
|
423
|
+
)
|
|
424
|
+
config = copy.deepcopy(template)
|
|
319
425
|
elif type == "function":
|
|
320
|
-
config =
|
|
426
|
+
config = copy.deepcopy(FUNCTION_CONFIG_TEMPLATE)
|
|
321
427
|
config["entryPoint"] = os.path.basename(file_path)
|
|
322
428
|
return config
|
|
323
429
|
|
|
@@ -372,22 +478,51 @@ def update_config(file_path: str) -> dict[str, Any]:
|
|
|
372
478
|
|
|
373
479
|
if package_type == "script":
|
|
374
480
|
existing_config["dataspace"] = get_dataspace(existing_config)
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if output.read_dlo:
|
|
378
|
-
read["dlo"] = list(output.read_dlo)
|
|
379
|
-
else:
|
|
380
|
-
read["dmo"] = list(output.read_dmo)
|
|
381
|
-
write: dict[str, list[str]] = {}
|
|
382
|
-
if output.write_to_dlo:
|
|
383
|
-
write["dlo"] = list(output.write_to_dlo)
|
|
481
|
+
if file_is_streaming(file_path):
|
|
482
|
+
_update_streaming_config(existing_config, file_path)
|
|
384
483
|
else:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
484
|
+
existing_config.pop("streamingSource", None)
|
|
485
|
+
output = scan_file(file_path)
|
|
486
|
+
read: dict[str, list[str]] = {}
|
|
487
|
+
if output.read_dlo:
|
|
488
|
+
read["dlo"] = list(output.read_dlo)
|
|
489
|
+
else:
|
|
490
|
+
read["dmo"] = list(output.read_dmo)
|
|
491
|
+
write: dict[str, list[str]] = {}
|
|
492
|
+
if output.write_to_dlo:
|
|
493
|
+
write["dlo"] = list(output.write_to_dlo)
|
|
494
|
+
else:
|
|
495
|
+
write["dmo"] = list(output.write_to_dmo)
|
|
496
|
+
|
|
497
|
+
existing_config["permissions"] = {"read": read, "write": write}
|
|
388
498
|
return existing_config
|
|
389
499
|
|
|
390
500
|
|
|
501
|
+
def _update_streaming_config(existing_config: dict[str, Any], file_path: str) -> None:
|
|
502
|
+
output = scan_file_streaming(file_path)
|
|
503
|
+
read_layer = output.read_layer
|
|
504
|
+
|
|
505
|
+
source = existing_config.get("streamingSource")
|
|
506
|
+
if not isinstance(source, dict):
|
|
507
|
+
source = {}
|
|
508
|
+
source_name = source.get("name", "")
|
|
509
|
+
existing_config["streamingSource"] = {"type": read_layer, "name": source_name}
|
|
510
|
+
|
|
511
|
+
if not source_name:
|
|
512
|
+
logger.warning(
|
|
513
|
+
"streamingSource.name is empty in config.json. A streaming "
|
|
514
|
+
"transform must declare its read source; set streamingSource.name "
|
|
515
|
+
"to the DLO/DMO the transform reads from."
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
read_names = [source_name] if source_name else []
|
|
519
|
+
write_names = list(output.write_dlo_deltas)
|
|
520
|
+
existing_config["permissions"] = {
|
|
521
|
+
"read": {read_layer: read_names},
|
|
522
|
+
"write": {"dlo": write_names},
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
391
526
|
def get_dataspace(existing_config: dict[str, str]) -> str:
|
|
392
527
|
if "dataspace" in existing_config:
|
|
393
528
|
dataspace_value = existing_config["dataspace"]
|
datacustomcode/template.py
CHANGED
|
@@ -23,8 +23,12 @@ from datacustomcode.constants import FEATURE_TEMPLATE_MAPPING
|
|
|
23
23
|
script_template_dir = os.path.join(os.path.dirname(__file__), "templates", "script")
|
|
24
24
|
function_template_dir = os.path.join(os.path.dirname(__file__), "templates", "function")
|
|
25
25
|
|
|
26
|
+
STREAMING_EXAMPLE_ENTRYPOINT = os.path.join(
|
|
27
|
+
script_template_dir, "examples", "streaming_deltas", "entrypoint.py"
|
|
28
|
+
)
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
|
|
31
|
+
def copy_script_template(target_dir: str, streaming: bool = False) -> None:
|
|
28
32
|
"""Copy the template to the target directory."""
|
|
29
33
|
os.makedirs(target_dir, exist_ok=True)
|
|
30
34
|
|
|
@@ -39,6 +43,14 @@ def copy_script_template(target_dir: str) -> None:
|
|
|
39
43
|
logger.debug(f"Copying file {source} to {destination}...")
|
|
40
44
|
shutil.copy2(source, destination)
|
|
41
45
|
|
|
46
|
+
if streaming:
|
|
47
|
+
destination = os.path.join(target_dir, "payload", "entrypoint.py")
|
|
48
|
+
logger.debug(
|
|
49
|
+
f"Copying streaming example {STREAMING_EXAMPLE_ENTRYPOINT} to "
|
|
50
|
+
f"{destination}..."
|
|
51
|
+
)
|
|
52
|
+
shutil.copy2(STREAMING_EXAMPLE_ENTRYPOINT, destination)
|
|
53
|
+
|
|
42
54
|
|
|
43
55
|
def copy_function_template(target_dir: str, use_in_feature: Optional[str]) -> None:
|
|
44
56
|
os.makedirs(target_dir, exist_ok=True)
|
|
@@ -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()
|