salesforce-data-customcode 7.0.0rc2__tar.gz → 7.0.0rc4__tar.gz
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.
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/PKG-INFO +47 -3
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/README.md +46 -2
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/pyproject.toml +7 -7
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/__init__.py +10 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/client.py +20 -10
- salesforce_data_customcode-7.0.0rc4/src/datacustomcode/io/cdf.py +34 -0
- salesforce_data_customcode-7.0.0rc4/src/datacustomcode/io/reader/local_deltas.py +164 -0
- salesforce_data_customcode-7.0.0rc4/src/datacustomcode/io/reader/streaming_seeder.py +114 -0
- salesforce_data_customcode-7.0.0rc4/src/datacustomcode/io/writer/local_deltas.py +133 -0
- salesforce_data_customcode-7.0.0rc4/src/datacustomcode/lineage/__init__.py +137 -0
- salesforce_data_customcode-7.0.0rc4/src/datacustomcode/lineage/_worker.py +97 -0
- salesforce_data_customcode-7.0.0rc4/src/datacustomcode/lineage/capture.py +289 -0
- salesforce_data_customcode-7.0.0rc4/src/datacustomcode/lineage/tracer.py +566 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/spark_base.py +8 -2
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/spark_default.py +40 -4
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/run.py +85 -1
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/template.py +20 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/LICENSE.txt +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/auth.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/cli.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/cmd.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/common_config.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/config.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/config.yaml +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/constants.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/credentials.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/deploy.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_platform_client.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_platform_config.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/errors.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/impl/default.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/spark_base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/spark_default.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/types.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions_config.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/file/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/file/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/file/path/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/file/path/default.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/feature_types/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/feature_types/chunking.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/runtime.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function_utils.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/query_api.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/sf_cli.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/utils.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/writer/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/writer/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/writer/csv.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/writer/print.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/default.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/errors.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/spark_base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/spark_default.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/generate_text_request.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/generate_text_request_builder.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/generate_text_response.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/generate_text_response_builder.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway_config.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/mixin.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/default.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/auth.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/credentials.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/transport.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/url_resolver.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/errors.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_method.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_request.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_request_builder.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_response.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_response_builder.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential_config.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/py.typed +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/scan.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/spark/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/spark/base.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/spark/column_hints.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/spark/default.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/__init__.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/.devcontainer/devcontainer.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/Dockerfile.dependencies +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/README.md +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/build_native_dependencies.sh +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/chunking/payload/config.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/chunking/payload/entrypoint.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/chunking/requirements.txt +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_llm/config.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_llm/entrypoint.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_llm/files/chunking_prompt.txt +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_llm/tests/test.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_prediction/config.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_prediction/entrypoint.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_prediction/tests/test.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/payload/config.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/payload/entrypoint.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/payload/utility.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/requirements-dev.txt +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/requirements.txt +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/.devcontainer/devcontainer.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/Dockerfile +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/Dockerfile.dependencies +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/README.md +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/account.ipynb +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/build_native_dependencies.sh +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/examples/employee_hierarchy/employee_data.csv +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/examples/employee_hierarchy/entrypoint.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/examples/streaming_deltas/entrypoint.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/jupyterlab.sh +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/payload/config.json +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/payload/entrypoint.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/requirements-dev.txt +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/requirements.txt +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/token_provider.py +0 -0
- {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: salesforce-data-customcode
|
|
3
|
-
Version: 7.0.
|
|
3
|
+
Version: 7.0.0rc4
|
|
4
4
|
Summary: Data Cloud Custom Code SDK
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE.txt
|
|
@@ -176,8 +176,8 @@ For a batch transform, use `Client`. You should only need the following methods:
|
|
|
176
176
|
* `find_file_path(file_name)` – Resolve a bundled file (placed under `payload/files/`) to a `pathlib.Path` that exists. Works the same locally and inside Data Cloud — see [Bundled file resolution](#bundled-file-resolution) below for the full lookup order. Raises `FileNotFoundError` if the file isn't found.
|
|
177
177
|
* `read_dlo(name)` – Read from a Data Lake Object by name
|
|
178
178
|
* `read_dmo(name)` – Read from a Data Model Object by name
|
|
179
|
-
* `write_to_dlo(name, spark_dataframe, write_mode)` – Write to a Data
|
|
180
|
-
* `write_to_dmo(name, spark_dataframe, write_mode)` – Write to a Data
|
|
179
|
+
* `write_to_dlo(name, spark_dataframe, write_mode)` – Write to a Data Lake Object by name with a Spark dataframe
|
|
180
|
+
* `write_to_dmo(name, spark_dataframe, write_mode)` – Write to a Data Model Object by name with a Spark dataframe
|
|
181
181
|
|
|
182
182
|
For a streaming (delta) transform, use `StreamingClient`, which exposes the streaming counterparts:
|
|
183
183
|
* `read_dlo_deltas()` – Read the streaming change feed (deltas) of a Data Lake Object as a streaming DataFrame.
|
|
@@ -201,6 +201,27 @@ client.write_to_dlo('output_DLO')
|
|
|
201
201
|
> [!WARNING]
|
|
202
202
|
> Currently we only support reading from DMOs and writing to DMOs or reading from DLOs and writing to DLOs, but they cannot mix.
|
|
203
203
|
|
|
204
|
+
### Writing Currency fields
|
|
205
|
+
|
|
206
|
+
A DLO or DMO with a Currency field has a system field, `cdp_sys_record_currency__c`, that holds each record's ISO currency code. Data Explorer UI uses it to display currency values, and shows a blank value if it's null (Query Editor still shows it).
|
|
207
|
+
|
|
208
|
+
Code Extensions don't set `cdp_sys_record_currency__c` for you, since only your script knows what currency its amounts are in. When writing to an object with Currency fields, set it yourself:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from pyspark.sql.functions import lit
|
|
212
|
+
|
|
213
|
+
# Either source has Currency fields: keep its cdp_sys_record_currency__c in your output
|
|
214
|
+
df = source.select("id__c", "amount__c", "cdp_sys_record_currency__c")
|
|
215
|
+
|
|
216
|
+
# OR amounts are in a known currency
|
|
217
|
+
df = df.withColumn("cdp_sys_record_currency__c", lit("EUR"))
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- Use an ISO code that exists in your org's configured currency rates.
|
|
221
|
+
- When aggregating, group by the currency code so amounts in different currencies aren't summed together.
|
|
222
|
+
|
|
223
|
+
See [`docs/examples/script/currency_totals`](docs/examples/script/currency_totals) for a complete example.
|
|
224
|
+
|
|
204
225
|
### Streaming (delta) transforms
|
|
205
226
|
|
|
206
227
|
Streaming BYOC transforms process a Data Lake Object's Change Data Feed continuously instead of reading a bounded snapshot. Use a `StreamingClient` and its `*_deltas` methods in place of the batch `Client` read/write methods:
|
|
@@ -249,6 +270,29 @@ If none of these exist, `find_file_path` raises `FileNotFoundError` with the lis
|
|
|
249
270
|
|
|
250
271
|
`$LIBRARY_PATH` is set automatically to the root of the package at runtime inside Data Cloud.
|
|
251
272
|
|
|
273
|
+
### Generating lineage
|
|
274
|
+
|
|
275
|
+
`generate_lineage` runs a script entry point locally and derives the field-level `dataLineage` section of `config.json` from the Spark plans of the DataFrames it writes:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from datacustomcode.lineage import generate_lineage
|
|
279
|
+
|
|
280
|
+
result = generate_lineage("payload/entrypoint.py", sf_cli_org="my-org")
|
|
281
|
+
print(result.to_dict()["dataLineage"])
|
|
282
|
+
# [{"targetField": "Output__dll.name__c",
|
|
283
|
+
# "sourceFields": ["Input__dll.first__c", "Input__dll.last__c"]}, ...]
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The entry point runs exactly as it would locally (same config, reader, and credentials), except that writes are traced and validated against the target DLO instead of being performed. It runs in a separate Python process, which inherits your working directory, environment, and `sys.path`; errors raised by the script are re-raised from `generate_lineage`. Reads use the reader's `default_row_limit` (1000 by default; set it in your `config.yaml`).
|
|
287
|
+
|
|
288
|
+
> **Review the output before you use it.** The generated lineage is a starting point for review, not a guarantee. It reflects only the code paths and data your local run exercised, and it can't see inside UDFs. Accurate and complete `dataLineage` in your package is your responsibility as the package author.
|
|
289
|
+
|
|
290
|
+
- A target field's sources are the fields its *value* is computed from. Fields that only decide which rows, groups or branch are used aren't sources, however the logic is written: filter conditions, join keys, `groupBy` keys, sort keys, window `partitionBy`/`orderBy` keys, `when()`/`if()` conditions, and aggregate `FILTER (WHERE …)` clauses. For example, `groupBy("region").agg(sum("amount"))` and `sum("amount").over(Window.partitionBy("region"))` both trace to `amount` only.
|
|
291
|
+
- Fields computed only from literals are listed in `noSources`. So are fields whose value doesn't come from any input field's value, such as `row_number()`, `rank()`, `count("*")`, or `sum(when(cond, 1))`.
|
|
292
|
+
- Fields that can't be traced are listed in `unresolved` with a reason and get no lineage. This covers data that didn't come from `read_dlo`/`read_dmo` (for example a `toPandas()` → `createDataFrame` round trip), `mapInPandas`, and subqueries.
|
|
293
|
+
- `pairCount` is the total number of (target, source) field pairs.
|
|
294
|
+
- Only batch script packages are supported.
|
|
295
|
+
|
|
252
296
|
|
|
253
297
|
## CLI
|
|
254
298
|
|
|
@@ -151,8 +151,8 @@ For a batch transform, use `Client`. You should only need the following methods:
|
|
|
151
151
|
* `find_file_path(file_name)` – Resolve a bundled file (placed under `payload/files/`) to a `pathlib.Path` that exists. Works the same locally and inside Data Cloud — see [Bundled file resolution](#bundled-file-resolution) below for the full lookup order. Raises `FileNotFoundError` if the file isn't found.
|
|
152
152
|
* `read_dlo(name)` – Read from a Data Lake Object by name
|
|
153
153
|
* `read_dmo(name)` – Read from a Data Model Object by name
|
|
154
|
-
* `write_to_dlo(name, spark_dataframe, write_mode)` – Write to a Data
|
|
155
|
-
* `write_to_dmo(name, spark_dataframe, write_mode)` – Write to a Data
|
|
154
|
+
* `write_to_dlo(name, spark_dataframe, write_mode)` – Write to a Data Lake Object by name with a Spark dataframe
|
|
155
|
+
* `write_to_dmo(name, spark_dataframe, write_mode)` – Write to a Data Model Object by name with a Spark dataframe
|
|
156
156
|
|
|
157
157
|
For a streaming (delta) transform, use `StreamingClient`, which exposes the streaming counterparts:
|
|
158
158
|
* `read_dlo_deltas()` – Read the streaming change feed (deltas) of a Data Lake Object as a streaming DataFrame.
|
|
@@ -176,6 +176,27 @@ client.write_to_dlo('output_DLO')
|
|
|
176
176
|
> [!WARNING]
|
|
177
177
|
> Currently we only support reading from DMOs and writing to DMOs or reading from DLOs and writing to DLOs, but they cannot mix.
|
|
178
178
|
|
|
179
|
+
### Writing Currency fields
|
|
180
|
+
|
|
181
|
+
A DLO or DMO with a Currency field has a system field, `cdp_sys_record_currency__c`, that holds each record's ISO currency code. Data Explorer UI uses it to display currency values, and shows a blank value if it's null (Query Editor still shows it).
|
|
182
|
+
|
|
183
|
+
Code Extensions don't set `cdp_sys_record_currency__c` for you, since only your script knows what currency its amounts are in. When writing to an object with Currency fields, set it yourself:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from pyspark.sql.functions import lit
|
|
187
|
+
|
|
188
|
+
# Either source has Currency fields: keep its cdp_sys_record_currency__c in your output
|
|
189
|
+
df = source.select("id__c", "amount__c", "cdp_sys_record_currency__c")
|
|
190
|
+
|
|
191
|
+
# OR amounts are in a known currency
|
|
192
|
+
df = df.withColumn("cdp_sys_record_currency__c", lit("EUR"))
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- Use an ISO code that exists in your org's configured currency rates.
|
|
196
|
+
- When aggregating, group by the currency code so amounts in different currencies aren't summed together.
|
|
197
|
+
|
|
198
|
+
See [`docs/examples/script/currency_totals`](docs/examples/script/currency_totals) for a complete example.
|
|
199
|
+
|
|
179
200
|
### Streaming (delta) transforms
|
|
180
201
|
|
|
181
202
|
Streaming BYOC transforms process a Data Lake Object's Change Data Feed continuously instead of reading a bounded snapshot. Use a `StreamingClient` and its `*_deltas` methods in place of the batch `Client` read/write methods:
|
|
@@ -224,6 +245,29 @@ If none of these exist, `find_file_path` raises `FileNotFoundError` with the lis
|
|
|
224
245
|
|
|
225
246
|
`$LIBRARY_PATH` is set automatically to the root of the package at runtime inside Data Cloud.
|
|
226
247
|
|
|
248
|
+
### Generating lineage
|
|
249
|
+
|
|
250
|
+
`generate_lineage` runs a script entry point locally and derives the field-level `dataLineage` section of `config.json` from the Spark plans of the DataFrames it writes:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
from datacustomcode.lineage import generate_lineage
|
|
254
|
+
|
|
255
|
+
result = generate_lineage("payload/entrypoint.py", sf_cli_org="my-org")
|
|
256
|
+
print(result.to_dict()["dataLineage"])
|
|
257
|
+
# [{"targetField": "Output__dll.name__c",
|
|
258
|
+
# "sourceFields": ["Input__dll.first__c", "Input__dll.last__c"]}, ...]
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The entry point runs exactly as it would locally (same config, reader, and credentials), except that writes are traced and validated against the target DLO instead of being performed. It runs in a separate Python process, which inherits your working directory, environment, and `sys.path`; errors raised by the script are re-raised from `generate_lineage`. Reads use the reader's `default_row_limit` (1000 by default; set it in your `config.yaml`).
|
|
262
|
+
|
|
263
|
+
> **Review the output before you use it.** The generated lineage is a starting point for review, not a guarantee. It reflects only the code paths and data your local run exercised, and it can't see inside UDFs. Accurate and complete `dataLineage` in your package is your responsibility as the package author.
|
|
264
|
+
|
|
265
|
+
- A target field's sources are the fields its *value* is computed from. Fields that only decide which rows, groups or branch are used aren't sources, however the logic is written: filter conditions, join keys, `groupBy` keys, sort keys, window `partitionBy`/`orderBy` keys, `when()`/`if()` conditions, and aggregate `FILTER (WHERE …)` clauses. For example, `groupBy("region").agg(sum("amount"))` and `sum("amount").over(Window.partitionBy("region"))` both trace to `amount` only.
|
|
266
|
+
- Fields computed only from literals are listed in `noSources`. So are fields whose value doesn't come from any input field's value, such as `row_number()`, `rank()`, `count("*")`, or `sum(when(cond, 1))`.
|
|
267
|
+
- Fields that can't be traced are listed in `unresolved` with a reason and get no lineage. This covers data that didn't come from `read_dlo`/`read_dmo` (for example a `toPandas()` → `createDataFrame` round trip), `mapInPandas`, and subqueries.
|
|
268
|
+
- `pairCount` is the total number of (target, source) field pairs.
|
|
269
|
+
- Only batch script packages are supported.
|
|
270
|
+
|
|
227
271
|
|
|
228
272
|
## CLI
|
|
229
273
|
|
|
@@ -18,7 +18,7 @@ license = "Apache-2.0"
|
|
|
18
18
|
name = "salesforce-data-customcode"
|
|
19
19
|
readme = "README.md"
|
|
20
20
|
requires-python = ">=3.10,<3.12"
|
|
21
|
-
version = "7.0.
|
|
21
|
+
version = "7.0.0rc4"
|
|
22
22
|
|
|
23
23
|
[tool.black]
|
|
24
24
|
exclude = '''
|
|
@@ -88,10 +88,10 @@ warn_unused_configs = true
|
|
|
88
88
|
|
|
89
89
|
[tool.poetry]
|
|
90
90
|
include = [
|
|
91
|
-
{path = "src/datacustomcode/templates/**/*"},
|
|
92
|
-
{path = "src/datacustomcode/config.yaml"}
|
|
91
|
+
{ path = "src/datacustomcode/templates/**/*" },
|
|
92
|
+
{ path = "src/datacustomcode/config.yaml" }
|
|
93
93
|
]
|
|
94
|
-
packages = [{include = "datacustomcode", from = "src"}]
|
|
94
|
+
packages = [{ include = "datacustomcode", from = "src" }]
|
|
95
95
|
|
|
96
96
|
[tool.poetry.build]
|
|
97
97
|
generate-setup-file = false
|
|
@@ -115,7 +115,7 @@ coverage = ">=7.0.0,<8.0.0"
|
|
|
115
115
|
ipykernel = "^6.29.5"
|
|
116
116
|
mypy = "*"
|
|
117
117
|
pipreqs = "*"
|
|
118
|
-
poetry-dynamic-versioning = {extras = ["plugin"], version = "^1.8.2"}
|
|
118
|
+
poetry-dynamic-versioning = { extras = ["plugin"], version = "^1.8.2" }
|
|
119
119
|
pre-commit = "*"
|
|
120
120
|
pytest = "*"
|
|
121
121
|
pytest-cov = "*"
|
|
@@ -127,7 +127,7 @@ types-requests = "*"
|
|
|
127
127
|
[tool.poetry.plugins."poetry.plugin"]
|
|
128
128
|
|
|
129
129
|
[tool.poetry.requires-plugins]
|
|
130
|
-
poetry-dynamic-versioning = {version = ">=1.0.0,<2.0.0", extras = ["plugin"]}
|
|
130
|
+
poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }
|
|
131
131
|
|
|
132
132
|
[tool.poetry.scripts]
|
|
133
133
|
datacustomcode = "datacustomcode.cli:cli"
|
|
@@ -152,7 +152,6 @@ testpaths = ["tests"]
|
|
|
152
152
|
[tool.ruff]
|
|
153
153
|
fix = true
|
|
154
154
|
line-length = 88
|
|
155
|
-
target-version = 'py310'
|
|
156
155
|
lint.ignore = [
|
|
157
156
|
# do not assign a lambda expression, use a def
|
|
158
157
|
'E731',
|
|
@@ -211,6 +210,7 @@ lint.select = [
|
|
|
211
210
|
'RUF',
|
|
212
211
|
'S102'
|
|
213
212
|
]
|
|
213
|
+
target-version = 'py310'
|
|
214
214
|
|
|
215
215
|
[tool.setuptools.package_data]
|
|
216
216
|
datacustomcode = ["templates/**/*", "config.yaml"]
|
|
@@ -24,6 +24,8 @@ __all__ = [
|
|
|
24
24
|
"Credentials",
|
|
25
25
|
"DefaultSparkEinsteinPredictions",
|
|
26
26
|
"DefaultSparkLLMGateway",
|
|
27
|
+
"LocalDeltasReader",
|
|
28
|
+
"LocalDeltasWriter",
|
|
27
29
|
"PrintDataCloudWriter",
|
|
28
30
|
"QueryAPIDataCloudReader",
|
|
29
31
|
"SparkEinsteinPredictions",
|
|
@@ -60,6 +62,14 @@ def __getattr__(name: str):
|
|
|
60
62
|
from datacustomcode.io.reader.query_api import QueryAPIDataCloudReader
|
|
61
63
|
|
|
62
64
|
return QueryAPIDataCloudReader
|
|
65
|
+
elif name == "LocalDeltasReader":
|
|
66
|
+
from datacustomcode.io.reader.local_deltas import LocalDeltasReader
|
|
67
|
+
|
|
68
|
+
return LocalDeltasReader
|
|
69
|
+
elif name == "LocalDeltasWriter":
|
|
70
|
+
from datacustomcode.io.writer.local_deltas import LocalDeltasWriter
|
|
71
|
+
|
|
72
|
+
return LocalDeltasWriter
|
|
63
73
|
elif name == "SparkLLMGateway":
|
|
64
74
|
from datacustomcode.llm_gateway import SparkLLMGateway
|
|
65
75
|
|
|
@@ -231,11 +231,12 @@ def einstein_predict_col(
|
|
|
231
231
|
def named_credential_request_col(
|
|
232
232
|
request: "HTTPRequest",
|
|
233
233
|
body: Optional["Column"] = None,
|
|
234
|
+
path: Optional["Column"] = None,
|
|
234
235
|
) -> "Column":
|
|
235
236
|
"""Build a Spark Column that makes one Named Credential callout per row.
|
|
236
237
|
|
|
237
|
-
The
|
|
238
|
-
``
|
|
238
|
+
The method and headers are fixed for the call (taken from ``request``);
|
|
239
|
+
``body`` and, optionally, ``path`` vary per row. Use this instead of
|
|
239
240
|
:meth:`Client.named_credential_request` when the callout runs across a
|
|
240
241
|
DataFrame so each row is dispatched independently rather than one-shot on
|
|
241
242
|
the driver.
|
|
@@ -253,13 +254,20 @@ def named_credential_request_col(
|
|
|
253
254
|
headers are applied to every row.
|
|
254
255
|
body: Optional per-row ``Column`` holding the request body as a
|
|
255
256
|
string (or null for no body).
|
|
257
|
+
path: Optional per-row ``Column`` appended to ``request.url``, e.g.
|
|
258
|
+
``concat(lit("?address="), col("address"))`` or ``lit("/v1/models")``.
|
|
259
|
+
The callout always stays on ``request.url``'s Named Credential: a
|
|
260
|
+
path not starting with ``/`` or ``?`` is joined with ``/``. A null
|
|
261
|
+
or empty row value uses ``request.url`` as is.
|
|
256
262
|
|
|
257
263
|
Returns:
|
|
258
264
|
A Spark ``Column`` of ``StructType`` with fields ``status``,
|
|
259
265
|
``response``, ``error_code``, and ``error_message``.
|
|
260
266
|
"""
|
|
261
267
|
named_credential = Client()._get_spark_named_credential()
|
|
262
|
-
|
|
268
|
+
if path is None:
|
|
269
|
+
return named_credential.request_col(request, body=body)
|
|
270
|
+
return named_credential.request_col(request, body=body, path=path)
|
|
263
271
|
|
|
264
272
|
|
|
265
273
|
class DataCloudObjectType(Enum):
|
|
@@ -368,7 +376,13 @@ class _BaseClient:
|
|
|
368
376
|
instance._spark_named_credential = spark_named_credential
|
|
369
377
|
# Initialize Readers and Writers from config
|
|
370
378
|
# and/or provided reader and writer
|
|
371
|
-
|
|
379
|
+
reader_forced = (
|
|
380
|
+
config.reader_config is not None and config.reader_config.force
|
|
381
|
+
)
|
|
382
|
+
writer_forced = (
|
|
383
|
+
config.writer_config is not None and config.writer_config.force
|
|
384
|
+
)
|
|
385
|
+
if reader is None or writer is None or reader_forced or writer_forced:
|
|
372
386
|
# We need a spark because we will initialize readers and writers.
|
|
373
387
|
# Reuse the process-wide session if one client already built it,
|
|
374
388
|
# so a Client and a StreamingClient share a single connection.
|
|
@@ -396,9 +410,7 @@ class _BaseClient:
|
|
|
396
410
|
raise ValueError(
|
|
397
411
|
"Reader config is required when reader is not provided"
|
|
398
412
|
)
|
|
399
|
-
elif reader is None or
|
|
400
|
-
config.reader_config is not None and config.reader_config.force
|
|
401
|
-
):
|
|
413
|
+
elif reader is None or reader_forced:
|
|
402
414
|
reader_init = config.reader_config.to_object(spark) # type: ignore
|
|
403
415
|
else:
|
|
404
416
|
reader_init = reader
|
|
@@ -406,9 +418,7 @@ class _BaseClient:
|
|
|
406
418
|
raise ValueError(
|
|
407
419
|
"Writer config is required when writer is not provided"
|
|
408
420
|
)
|
|
409
|
-
elif writer is None or
|
|
410
|
-
config.writer_config is not None and config.writer_config.force
|
|
411
|
-
):
|
|
421
|
+
elif writer is None or writer_forced:
|
|
412
422
|
writer_init = config.writer_config.to_object(spark) # type: ignore
|
|
413
423
|
else:
|
|
414
424
|
writer_init = writer
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Final
|
|
19
|
+
|
|
20
|
+
from datacustomcode.io.writer.base import MERGE_RECORD_TYPE_COLUMN, MergeRecordType
|
|
21
|
+
|
|
22
|
+
COMMIT_VERSION: Final = "_commit_version"
|
|
23
|
+
COMMIT_TIMESTAMP: Final = "_commit_timestamp"
|
|
24
|
+
MERGE_RECORD_TYPE: Final = MERGE_RECORD_TYPE_COLUMN
|
|
25
|
+
|
|
26
|
+
CDF_METADATA_COLUMNS: Final = (COMMIT_VERSION, COMMIT_TIMESTAMP, MERGE_RECORD_TYPE)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"COMMIT_VERSION",
|
|
30
|
+
"COMMIT_TIMESTAMP",
|
|
31
|
+
"MERGE_RECORD_TYPE",
|
|
32
|
+
"CDF_METADATA_COLUMNS",
|
|
33
|
+
"MergeRecordType",
|
|
34
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import sys
|
|
22
|
+
from typing import (
|
|
23
|
+
TYPE_CHECKING,
|
|
24
|
+
Optional,
|
|
25
|
+
Union,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from datacustomcode.config import config
|
|
29
|
+
from datacustomcode.io import cdf
|
|
30
|
+
from datacustomcode.io.reader.base import BaseDataCloudReader
|
|
31
|
+
from datacustomcode.io.reader.query_api import QueryAPIDataCloudReader
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from pyspark.sql import DataFrame as PySparkDataFrame, SparkSession
|
|
35
|
+
from pyspark.sql.types import AtomicType, StructType
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LocalDeltasReader(BaseDataCloudReader):
|
|
41
|
+
"""Local reader for streaming transforms"""
|
|
42
|
+
|
|
43
|
+
CONFIG_NAME = "LocalDeltasReader"
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
spark: SparkSession,
|
|
48
|
+
credentials_profile: str = "default",
|
|
49
|
+
dataspace: Optional[str] = None,
|
|
50
|
+
sf_cli_org: Optional[str] = None,
|
|
51
|
+
default_row_limit: Optional[int] = None,
|
|
52
|
+
fixtures_root: str = "payload/streaming_fixtures",
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(spark)
|
|
55
|
+
self._fixtures_root = Path(fixtures_root)
|
|
56
|
+
self._credentials_profile = credentials_profile
|
|
57
|
+
self._dataspace = dataspace
|
|
58
|
+
self._sf_cli_org = sf_cli_org
|
|
59
|
+
# Reuse the batch reader for INITIAL_SYNC / REBUILD reads.
|
|
60
|
+
self._batch = QueryAPIDataCloudReader(
|
|
61
|
+
spark=spark,
|
|
62
|
+
credentials_profile=credentials_profile,
|
|
63
|
+
dataspace=dataspace,
|
|
64
|
+
sf_cli_org=sf_cli_org,
|
|
65
|
+
default_row_limit=default_row_limit,
|
|
66
|
+
)
|
|
67
|
+
self._current_layer = "dlo"
|
|
68
|
+
|
|
69
|
+
def read_dlo(
|
|
70
|
+
self,
|
|
71
|
+
name: str,
|
|
72
|
+
schema: Union[AtomicType, StructType, str, None] = None,
|
|
73
|
+
) -> PySparkDataFrame:
|
|
74
|
+
return self._batch.read_dlo(name, schema)
|
|
75
|
+
|
|
76
|
+
def read_dmo(
|
|
77
|
+
self,
|
|
78
|
+
name: str,
|
|
79
|
+
schema: Union[AtomicType, StructType, str, None] = None,
|
|
80
|
+
) -> PySparkDataFrame:
|
|
81
|
+
return self._batch.read_dmo(name, schema)
|
|
82
|
+
|
|
83
|
+
def read_dlo_deltas(self) -> PySparkDataFrame:
|
|
84
|
+
self._current_layer = "dlo"
|
|
85
|
+
return self._open_stream(self._streaming_source())
|
|
86
|
+
|
|
87
|
+
def read_dmo_deltas(self) -> PySparkDataFrame:
|
|
88
|
+
self._current_layer = "dmo"
|
|
89
|
+
return self._open_stream(self._streaming_source())
|
|
90
|
+
|
|
91
|
+
def _streaming_source(self) -> str:
|
|
92
|
+
source = config.streaming_source
|
|
93
|
+
if not source:
|
|
94
|
+
raise RuntimeError(
|
|
95
|
+
"No streaming source configured. Set streamingSource.name in "
|
|
96
|
+
"config.json."
|
|
97
|
+
)
|
|
98
|
+
return source
|
|
99
|
+
|
|
100
|
+
def _open_stream(self, name: str) -> PySparkDataFrame:
|
|
101
|
+
drop_dir = self._fixtures_root / name
|
|
102
|
+
|
|
103
|
+
if not (drop_dir / "_schema.json").exists():
|
|
104
|
+
seeded = self._try_seed(name)
|
|
105
|
+
if not seeded:
|
|
106
|
+
print(
|
|
107
|
+
f"\nStreaming source {self._current_layer}='{name}' is "
|
|
108
|
+
f"empty.\n"
|
|
109
|
+
f" Populate the stream source with at least one row in "
|
|
110
|
+
f"Data Cloud, then re-run `datacustomcode run`.\n"
|
|
111
|
+
)
|
|
112
|
+
sys.exit(0)
|
|
113
|
+
|
|
114
|
+
print(
|
|
115
|
+
f"\nWrote sample streaming fixture in: {drop_dir} "
|
|
116
|
+
f"based on the contents of the streaming source {name}.\n"
|
|
117
|
+
f" You may add more JSON files alongside it to simulate "
|
|
118
|
+
f"additional changes.\n"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
schema = self._build_stream_schema(name)
|
|
122
|
+
return (
|
|
123
|
+
self.spark.readStream.format("json")
|
|
124
|
+
.schema(schema)
|
|
125
|
+
.option("maxFilesPerTrigger", 1) # one file = one batch
|
|
126
|
+
.option("latestFirst", "false") # oldest mtime first
|
|
127
|
+
.load(str(drop_dir))
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _build_stream_schema(self, name: str) -> "StructType":
|
|
131
|
+
"""Compose (source schema + CDF metadata columns) for readStream."""
|
|
132
|
+
from pyspark.sql.types import (
|
|
133
|
+
LongType,
|
|
134
|
+
StringType,
|
|
135
|
+
StructField,
|
|
136
|
+
StructType,
|
|
137
|
+
TimestampType,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
source_schema = self._resolve_source_schema(name)
|
|
141
|
+
cdf_fields = [
|
|
142
|
+
StructField(cdf.COMMIT_VERSION, LongType(), True),
|
|
143
|
+
StructField(cdf.COMMIT_TIMESTAMP, TimestampType(), True),
|
|
144
|
+
StructField(cdf.MERGE_RECORD_TYPE, StringType(), True),
|
|
145
|
+
]
|
|
146
|
+
return StructType(list(source_schema.fields) + cdf_fields)
|
|
147
|
+
|
|
148
|
+
def _resolve_source_schema(self, name: str) -> "StructType":
|
|
149
|
+
from pyspark.sql.types import StructType
|
|
150
|
+
|
|
151
|
+
schema_file = self._fixtures_root / name / "_schema.json"
|
|
152
|
+
return StructType.fromJson(json.loads(schema_file.read_text()))
|
|
153
|
+
|
|
154
|
+
def _try_seed(self, name: str) -> bool:
|
|
155
|
+
"""Creates source schema and sample change file."""
|
|
156
|
+
from datacustomcode.io.reader.streaming_seeder import StreamingSourceSeeder
|
|
157
|
+
|
|
158
|
+
seeder = StreamingSourceSeeder(
|
|
159
|
+
spark=self.spark,
|
|
160
|
+
credentials_profile=self._credentials_profile,
|
|
161
|
+
dataspace=self._dataspace,
|
|
162
|
+
sf_cli_org=self._sf_cli_org,
|
|
163
|
+
)
|
|
164
|
+
return seeder.seed_source(name, self._current_layer, str(self._fixtures_root))
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import (
|
|
23
|
+
TYPE_CHECKING,
|
|
24
|
+
Any,
|
|
25
|
+
Optional,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from datacustomcode.io import cdf
|
|
29
|
+
from datacustomcode.io.reader.query_api import QueryAPIDataCloudReader
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
import pandas as pd
|
|
33
|
+
from pyspark.sql import SparkSession
|
|
34
|
+
|
|
35
|
+
SEED_LIMIT = 10
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _clean_for_json(value: Any) -> Any:
|
|
39
|
+
if isinstance(value, float):
|
|
40
|
+
if math.isnan(value):
|
|
41
|
+
return None
|
|
42
|
+
if value.is_integer():
|
|
43
|
+
return int(value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StreamingSourceSeeder:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
spark: SparkSession,
|
|
51
|
+
credentials_profile: str = "default",
|
|
52
|
+
dataspace: Optional[str] = None,
|
|
53
|
+
sf_cli_org: Optional[str] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.spark = spark
|
|
56
|
+
self.reader = QueryAPIDataCloudReader(
|
|
57
|
+
spark=spark,
|
|
58
|
+
credentials_profile=credentials_profile,
|
|
59
|
+
dataspace=dataspace,
|
|
60
|
+
sf_cli_org=sf_cli_org,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def seed_source(self, name: str, layer: str, fixtures_root: str) -> bool:
|
|
64
|
+
"""Seed streaming fixtures and source schema.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
``True`` when schema + fixtures are written
|
|
68
|
+
``False`` when the source is empty.
|
|
69
|
+
Query API errors (missing creds, missing
|
|
70
|
+
source) propagate as ``RuntimeError``.
|
|
71
|
+
"""
|
|
72
|
+
read_fn = self.reader.read_dlo if layer == "dlo" else self.reader.read_dmo
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
head_df = read_fn(name).limit(1)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise RuntimeError(f"Failed to read {layer}='{name}': {exc}.") from exc
|
|
78
|
+
|
|
79
|
+
head_pandas = head_df.toPandas()
|
|
80
|
+
if len(head_pandas) == 0:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
schema = head_df.schema
|
|
84
|
+
out_dir = Path(fixtures_root) / name
|
|
85
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
schema_file_path = out_dir / "_schema.json"
|
|
87
|
+
schema_file_path.write_text(json.dumps(schema.jsonValue()))
|
|
88
|
+
print(
|
|
89
|
+
f"\nStreaming source schema written to {schema_file_path} "
|
|
90
|
+
f"({len(schema.fields)} fields)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Mix UPSERT and DELETE in the seed so the customer sees both
|
|
94
|
+
# operation types in the starter fixture. Annotated because
|
|
95
|
+
# pyspark's ``.toPandas()`` return type is ``PandasDataFrameLike``,
|
|
96
|
+
# which mypy doesn't recognize as having ``to_dict``.
|
|
97
|
+
snapshot: pd.DataFrame = read_fn(name).limit(SEED_LIMIT).toPandas()
|
|
98
|
+
seed_rows = []
|
|
99
|
+
for i, record in enumerate(snapshot.to_dict("records")):
|
|
100
|
+
op = cdf.MergeRecordType.DELETE if i % 2 else cdf.MergeRecordType.UPSERT
|
|
101
|
+
|
|
102
|
+
cleaned = {k: _clean_for_json(v) for k, v in record.items()}
|
|
103
|
+
seed_rows.append(
|
|
104
|
+
{
|
|
105
|
+
**cleaned,
|
|
106
|
+
cdf.COMMIT_VERSION: i + 1,
|
|
107
|
+
cdf.COMMIT_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
|
108
|
+
cdf.MERGE_RECORD_TYPE: op.value,
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
(out_dir / "000_seed.json").write_text(
|
|
112
|
+
"\n".join(json.dumps(r, default=str) for r in seed_rows)
|
|
113
|
+
)
|
|
114
|
+
return True
|