Skip to content

Commit f677b33

Browse files
authored
Merge pull request #137 from forcedotcom/noWriteModeV2
Added auto_write* methods to writers and clients.
2 parents 5317119 + 540ac80 commit f677b33

12 files changed

Lines changed: 314 additions & 34 deletions

File tree

‎.github/workflows/sf_cli_integration.yml‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ jobs:
1717
- name: Checkout code
1818
uses: actions/checkout@v4
1919

20+
- name: Set mock server TLS cert paths
21+
run: |
22+
echo "MOCK_SF_CERT_FILE=$RUNNER_TEMP/mock_sf_cert.pem" >> "$GITHUB_ENV"
23+
echo "MOCK_SF_KEY_FILE=$RUNNER_TEMP/mock_sf_key.pem" >> "$GITHUB_ENV"
24+
25+
- name: Generate mock server TLS cert
26+
run: |
27+
openssl req -x509 -newkey rsa:2048 -nodes \
28+
-keyout "$MOCK_SF_KEY_FILE" -out "$MOCK_SF_CERT_FILE" \
29+
-days 1 -subj "/CN=localhost" \
30+
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
31+
2032
- name: Set up Python 3.11
2133
uses: actions/setup-python@v5
2234
with:
@@ -73,7 +85,7 @@ jobs:
7385
sfdx_dir.mkdir(exist_ok=True)
7486
auth = {
7587
"accessToken": "00D000000000001AAA!fakeTokenForCITesting",
76-
"instanceUrl": "http://localhost:8888",
88+
"instanceUrl": "https://localhost:8888",
7789
"loginUrl": "https://login.salesforce.com",
7890
"orgId": "00D000000000001AAA",
7991
"userId": "005000000000001AAA",
@@ -164,6 +176,9 @@ jobs:
164176
# ── Script: run ───────────────────────────────────────────────────────────
165177

166178
- name: '[script] run — sf data-code-extension script run --entrypoint testScript/payload/entrypoint.py -o dev1'
179+
env:
180+
NODE_EXTRA_CA_CERTS: ${{ env.MOCK_SF_CERT_FILE }}
181+
REQUESTS_CA_BUNDLE: ${{ env.MOCK_SF_CERT_FILE }}
167182
run: |
168183
sf data-code-extension script run \
169184
--entrypoint testScript/payload/entrypoint.py \
@@ -175,6 +190,9 @@ jobs:
175190
# ── Script: deploy ───────────────────────────────────────────────────────
176191

177192
- name: '[script] deploy — sf data-code-extension script deploy'
193+
env:
194+
NODE_EXTRA_CA_CERTS: ${{ env.MOCK_SF_CERT_FILE }}
195+
REQUESTS_CA_BUNDLE: ${{ env.MOCK_SF_CERT_FILE }}
178196
run: |
179197
sf data-code-extension script deploy \
180198
--name test-script-deploy \
@@ -262,6 +280,9 @@ jobs:
262280
# ── Function: run ─────────────────────────────────────────────────────────
263281

264282
- name: '[function] run — sf data-code-extension function run --entrypoint testFunction/payload/entrypoint.py --test-with testFunction/payload/tests/test.json -o dev1'
283+
env:
284+
NODE_EXTRA_CA_CERTS: ${{ env.MOCK_SF_CERT_FILE }}
285+
REQUESTS_CA_BUNDLE: ${{ env.MOCK_SF_CERT_FILE }}
265286
run: |
266287
sf data-code-extension function run \
267288
--entrypoint testFunction/payload/entrypoint.py \
@@ -273,6 +294,9 @@ jobs:
273294
# ── Function: deploy ─────────────────────────────────────────────────────
274295

275296
- name: '[function] deploy — sf data-code-extension function deploy'
297+
env:
298+
NODE_EXTRA_CA_CERTS: ${{ env.MOCK_SF_CERT_FILE }}
299+
REQUESTS_CA_BUNDLE: ${{ env.MOCK_SF_CERT_FILE }}
276300
run: |
277301
sf data-code-extension function deploy \
278302
--name test-function-deploy \

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ exploration. Instead of running an entire script, one can run one code cell at
509509

510510
You can read more about Jupyter Notebooks here: https://jupyter.org/
511511

512-
1. Within the root project of your package folder, run `./jupyterlab.sh start`
512+
1. Within the root project of your package folder, run `./jupyterlab.sh start`. This prints an access token and opens an already-authenticated JupyterLab session in your browser. If the browser doesn't open automatically, copy the printed `http://localhost:8888/?token=...` URL into your browser.
513513
1. Double-click on "account.ipynb" file, which provides a starting point for a notebook
514514
1. Use shift+enter to execute each cell within the notebook. Add/edit/delete cells of code as needed for your data exploration.
515515
1. Don't forget to run `./jupyterlab.sh stop` to stop the docker container
@@ -600,4 +600,4 @@ If you're using OAuth Tokens authentication, the initial configure will retrieve
600600
## Other docs
601601

602602
- [Troubleshooting](./docs/troubleshooting.md)
603-
- [For Contributors](./FOR_CONTRIBUTORS.md)
603+
- [Contributing](./CONTRIBUTING.md)

‎scripts/mock_sf_server.py‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,25 @@
4040
python scripts/mock_sf_server.py # listens on port 8888
4141
MOCK_SF_PORT=9000 python scripts/mock_sf_server.py
4242
python scripts/mock_sf_server.py 9000
43+
44+
Serves TLS (the deploy path requires an HTTPS upload URL) using a pre-generated
45+
cert/key pair — this script does not generate one. Set ``MOCK_SF_CERT_FILE`` /
46+
``MOCK_SF_KEY_FILE`` to the pair's paths; generate a throwaway one with:
47+
48+
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \\
49+
-days 1 -subj "/CN=localhost" \\
50+
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
51+
52+
Point clients at the cert so they trust it: ``NODE_EXTRA_CA_CERTS`` (CLI) and
53+
``REQUESTS_CA_BUNDLE`` (SDK).
4354
"""
4455

4556
from __future__ import annotations
4657

4758
from http.server import BaseHTTPRequestHandler, HTTPServer
4859
import json
4960
import os
61+
import ssl
5062
import sys
5163

5264
PORT = (
@@ -68,7 +80,7 @@
6880

6981
_TOKEN_RESPONSE = {
7082
"access_token": "00D000000000001AAA!fakeAccessTokenForCITesting",
71-
"instance_url": f"http://localhost:{PORT}",
83+
"instance_url": f"https://localhost:{PORT}",
7284
"token_type": "Bearer",
7385
"scope": "api",
7486
}
@@ -135,7 +147,9 @@ def do_POST(self) -> None:
135147
elif path == _DATA_CUSTOM_CODE_PATH:
136148
# create_deployment() — return a presigned upload URL
137149
self._send_json(
138-
{"fileUploadUrl": f"http://localhost:{PORT}/upload/fake-deployment.zip"}
150+
{
151+
"fileUploadUrl": f"https://localhost:{PORT}/upload/fake-deployment.zip"
152+
}
139153
)
140154
elif path == _DATA_TRANSFORMS_PATH:
141155
# create_data_transform() — script packages only
@@ -150,7 +164,18 @@ def do_PUT(self) -> None:
150164

151165

152166
if __name__ == "__main__":
167+
cert_path = os.environ.get("MOCK_SF_CERT_FILE")
168+
key_path = os.environ.get("MOCK_SF_KEY_FILE")
169+
if not cert_path or not key_path:
170+
sys.exit(
171+
"MOCK_SF_CERT_FILE and MOCK_SF_KEY_FILE must both be set to an "
172+
"existing TLS cert/key pair — see the module docstring."
173+
)
174+
153175
server = HTTPServer(("localhost", PORT), MockSFHandler)
154176
server.allow_reuse_address = True
155-
print(f"[MOCK SF] Listening on http://localhost:{PORT}", flush=True)
177+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
178+
ctx.load_cert_chain(cert_path, key_path)
179+
server.socket = ctx.wrap_socket(server.socket, server_side=True)
180+
print(f"[MOCK SF] Listening on https://localhost:{PORT}", flush=True)
156181
server.serve_forever()

‎src/datacustomcode/client.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
from enum import Enum
18+
import os
1819
from typing import (
1920
TYPE_CHECKING,
2021
Any,
@@ -558,6 +559,15 @@ class StreamingClient(_BaseClient):
558559

559560
_instance: ClassVar[Optional[StreamingClient]] = None
560561

562+
def read_dlo(self) -> PySparkDataFrame:
563+
"""Read the streamingSource
564+
565+
Returns:
566+
A standard PySpark DataFrame from the streaming source DLO
567+
"""
568+
self._record_dlo_access(_streaming_source_name())
569+
return self._reader.read_dlo(_streaming_source_name())
570+
561571
def read_dlo_deltas(self) -> PySparkDataFrame:
562572
"""Read the streaming change feed (deltas) for a DLO from Data Cloud.
563573
@@ -571,6 +581,14 @@ def read_dlo_deltas(self) -> PySparkDataFrame:
571581
self._record_dlo_access(_streaming_source_name())
572582
return self._reader.read_dlo_deltas() # type: ignore[no-any-return]
573583

584+
def read_dmo(self) -> PySparkDataFrame:
585+
"""Read the streamingSource
586+
587+
Returns a standard PySpark DataFrame from the streaming source DMO
588+
"""
589+
self._record_dmo_access(_streaming_source_name())
590+
return self._reader.read_dmo(_streaming_source_name())
591+
574592
def read_dmo_deltas(self) -> PySparkDataFrame:
575593
"""Read the streaming change feed (deltas) for a DMO from Data Cloud.
576594
@@ -598,3 +616,41 @@ def write_dlo_deltas(
598616
"""
599617
self._validate_data_layer_history_does_not_contain(DataCloudObjectType.DMO)
600618
return self._writer.write_dlo_deltas(name, dataframe, **kwargs) # type: ignore[no-any-return]
619+
620+
def auto_write_to_dlo(self, name: str, dataframe: PySparkDataFrame) -> None:
621+
"""Write a PySpark DataFrame to a DLO in Data Cloud automatically picking
622+
the WriteMode.
623+
For use with streaming transforms when running in rebuild or initial sync mode.
624+
Args:
625+
name: The name of the DLO to write to.
626+
dataframe: The PySpark DataFrame to write.
627+
"""
628+
self._validate_data_layer_history_does_not_contain(DataCloudObjectType.DMO)
629+
return self._writer.auto_write_to_dlo(name, dataframe)
630+
631+
def auto_write_to_dmo(self, name: str, dataframe: PySparkDataFrame) -> None:
632+
"""Write a PySpark DataFrame to a DMO in Data Cloud automatically picking
633+
the WriteMode.
634+
For use with streaming transforms when running in rebuild or initial sync mode.
635+
Args:
636+
name: The name of the DMO to write to.
637+
dataframe: The PySpark DataFrame to write.
638+
"""
639+
self._validate_data_layer_history_does_not_contain(DataCloudObjectType.DLO)
640+
return self._writer.auto_write_to_dmo(name, dataframe)
641+
642+
643+
class RunMode(Enum):
644+
BATCH = "BATCH"
645+
INITIAL_SYNC = "INITIAL_SYNC"
646+
REBUILD = "REBUILD"
647+
DELTA_SYNC = "DELTA_SYNC"
648+
649+
650+
def get_run_mode() -> RunMode:
651+
"""Read and validate the BYOC_RUN_MODE env var; default to BATCH when unset."""
652+
run_mode = os.getenv("BYOC_RUN_MODE", "BATCH").upper()
653+
try:
654+
return RunMode(run_mode)
655+
except ValueError as exc:
656+
raise ValueError("Set BYOC_RUN_MODE to a valid value") from exc

‎src/datacustomcode/io/writer/base.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ def write_to_dmo(
5959
self, name: str, dataframe: PySparkDataFrame, write_mode: WriteMode
6060
) -> None: ...
6161

62+
def auto_write_to_dlo(self, name: str, dataframe: PySparkDataFrame) -> None:
63+
"""Write to a DLO automatically picking the write mode.
64+
For use with streaming transforms when running in rebuild or initial sync mode.
65+
"""
66+
raise NotImplementedError
67+
68+
def auto_write_to_dmo(self, name: str, dataframe: PySparkDataFrame) -> None:
69+
"""Write to a DMO automatically picking the write mode.
70+
For use with streaming transforms when running in rebuild or initial sync mode.
71+
"""
72+
raise NotImplementedError
73+
6274
def write_dlo_deltas(
6375
self, name: str, dataframe: PySparkDataFrame
6476
) -> StreamingQuery:

‎src/datacustomcode/io/writer/csv.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,18 @@ def write_to_dlo(
3636
name = f"{name}{SUFFIX}"
3737
dataframe.write.csv(name, mode=write_mode)
3838

39+
def auto_write_to_dlo(self, name: str, dataframe: PySparkDataFrame) -> None:
40+
# use overwrite since this is a local only writer
41+
self.write_to_dlo(name, dataframe, WriteMode.OVERWRITE)
42+
3943
def write_to_dmo(
4044
self, name: str, dataframe: PySparkDataFrame, write_mode: WriteMode
4145
) -> None:
4246
# Only add the suffix if it's not already there
4347
if not name.lower().endswith(SUFFIX):
4448
name = f"{name}{SUFFIX}"
4549
dataframe.write.csv(name, mode=write_mode)
50+
51+
def auto_write_to_dmo(self, name: str, dataframe: PySparkDataFrame) -> None:
52+
# use overwrite since this is a local only writer
53+
self.write_to_dmo(name, dataframe, WriteMode.OVERWRITE)

‎src/datacustomcode/io/writer/print.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ def write_to_dlo(
122122

123123
dataframe.show()
124124

125+
def auto_write_to_dlo(self, name: str, dataframe: PySparkDataFrame) -> None:
126+
self.validate_dataframe_columns_against_dlo(dataframe, name)
127+
dataframe.show()
128+
125129
def write_to_dmo(
126130
self, name: str, dataframe: PySparkDataFrame, write_mode: WriteMode
127131
) -> None:
@@ -130,3 +134,6 @@ def write_to_dmo(
130134
# so just show the dataframe.
131135

132136
dataframe.show()
137+
138+
def auto_write_to_dmo(self, name: str, dataframe: PySparkDataFrame) -> None:
139+
dataframe.show()

‎src/datacustomcode/templates/script/examples/streaming_deltas/entrypoint.py‎

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,46 +3,59 @@
33
This example is the streaming counterpart to a normal batch entrypoint. Instead
44
of a batch ``Client`` with ``read_dlo`` / ``write_to_dlo`` (which read and write
55
a bounded snapshot), it uses a :class:`StreamingClient` and its streaming delta
6-
methods:
6+
methods.
77
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.
8+
The first run of a streaming job will use the run mode INITIAL_SYNC which behaves
9+
like a batch run on the streaming source. A streaming transform can also use run
10+
mode REBUILD to do the same thing on demand. Note that these will process all
11+
source rows and overwrite the target.
1512
1613
The transform in between is ordinary PySpark. Because the source is a change
1714
feed, keep the metadata columns on the DataFrame you hand to
1815
``write_dlo_deltas`` — the sink relies on them to merge changes correctly.
1916
20-
This entrypoint only runs inside the Data Cloud streaming (``DELTA_SYNC``)
21-
runtime; the local ``datacustomcode run`` readers/writers raise
17+
This entrypoint only runs inside the Data Cloud runtime;
18+
the local ``datacustomcode run`` readers/writers raise
2219
``NotImplementedError`` for the delta methods.
2320
"""
2421

22+
from pyspark.sql import DataFrame
2523
from pyspark.sql.functions import col, upper
2624

27-
from datacustomcode.client import StreamingClient
25+
from datacustomcode.client import (
26+
RunMode,
27+
StreamingClient,
28+
get_run_mode,
29+
)
2830

2931

3032
def main():
33+
target_dlo = "Account_std_copy__dll"
3134
client = StreamingClient()
3235

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()
36+
if get_run_mode() == RunMode.DELTA_SYNC:
37+
# Streaming DataFrame over the source DLO's change feed.
38+
dataframe = client.read_dlo_deltas()
39+
# Ordinary PySpark transform.
40+
transformed = transform(dataframe)
41+
42+
# Start the streaming write. write_dlo_deltas returns the StreamingQuery;
43+
# the trigger and checkpoint location are provided by the runtime.
44+
query = client.write_dlo_deltas(target_dlo, transformed)
45+
46+
# Drive the query's lifecycle. In the streaming runtime this blocks until
47+
# the job is stopped by the platform.
48+
query.awaitTermination()
49+
else:
50+
# initial sync and rebuild read the entire streaming source DLO and
51+
# write using a server-decided mode based on the run mode
52+
dataframe = client.read_dlo()
53+
transformed = transform(dataframe)
54+
client.auto_write_to_dlo(target_dlo, transformed)
55+
56+
57+
def transform(dataframe: DataFrame) -> DataFrame:
58+
return dataframe.withColumn("description__c", upper(col("description__c")))
4659

4760

4861
if __name__ == "__main__":

0 commit comments

Comments
 (0)