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.
@@ -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
- 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)
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=error_code,
204
- error_message=str(response.data) if response.data else None,
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
- response = _call_predictions(
217
- predictions, model_api_name, prediction_type, features, settings
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": _extract_error_code(response),
224
- "error_message": str(response.data) if response.data else None,
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
- 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
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
+ }
@@ -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:
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 = None
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 scan_file(file_path: str) -> DataAccessLayerCalls:
305
- """Scan a single Python file for Client read/write method calls."""
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
- code = f.read()
308
- tree = ast.parse(code)
309
- visitor = ClientMethodVisitor()
310
- visitor.visit(tree)
311
- return visitor.found()
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
- def dc_config_json_from_file(file_path: str, type: str) -> dict[str, Any]:
315
- """Create a Data Cloud Custom Code config JSON from a script."""
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
- config = DATA_TRANSFORM_CONFIG_TEMPLATE.copy()
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 = FUNCTION_CONFIG_TEMPLATE.copy()
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
- output = scan_file(file_path)
376
- read: dict[str, list[str]] = {}
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
- write["dmo"] = list(output.write_to_dmo)
386
-
387
- existing_config["permissions"] = {"read": read, "write": write}
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"]
@@ -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
- def copy_script_template(target_dir: str) -> None:
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()