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.
Files changed (129) hide show
  1. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/PKG-INFO +47 -3
  2. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/README.md +46 -2
  3. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/pyproject.toml +7 -7
  4. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/__init__.py +10 -0
  5. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/client.py +20 -10
  6. salesforce_data_customcode-7.0.0rc4/src/datacustomcode/io/cdf.py +34 -0
  7. salesforce_data_customcode-7.0.0rc4/src/datacustomcode/io/reader/local_deltas.py +164 -0
  8. salesforce_data_customcode-7.0.0rc4/src/datacustomcode/io/reader/streaming_seeder.py +114 -0
  9. salesforce_data_customcode-7.0.0rc4/src/datacustomcode/io/writer/local_deltas.py +133 -0
  10. salesforce_data_customcode-7.0.0rc4/src/datacustomcode/lineage/__init__.py +137 -0
  11. salesforce_data_customcode-7.0.0rc4/src/datacustomcode/lineage/_worker.py +97 -0
  12. salesforce_data_customcode-7.0.0rc4/src/datacustomcode/lineage/capture.py +289 -0
  13. salesforce_data_customcode-7.0.0rc4/src/datacustomcode/lineage/tracer.py +566 -0
  14. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/spark_base.py +8 -2
  15. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/spark_default.py +40 -4
  16. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/run.py +85 -1
  17. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/template.py +20 -0
  18. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/LICENSE.txt +0 -0
  19. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/auth.py +0 -0
  20. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/cli.py +0 -0
  21. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/cmd.py +0 -0
  22. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/common_config.py +0 -0
  23. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/config.py +0 -0
  24. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/config.yaml +0 -0
  25. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/constants.py +0 -0
  26. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/credentials.py +0 -0
  27. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/deploy.py +0 -0
  28. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_platform_client.py +0 -0
  29. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_platform_config.py +0 -0
  30. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/__init__.py +0 -0
  31. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/base.py +0 -0
  32. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/errors.py +0 -0
  33. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/impl/default.py +0 -0
  34. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/spark_base.py +0 -0
  35. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/spark_default.py +0 -0
  36. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions/types.py +0 -0
  37. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/einstein_predictions_config.py +0 -0
  38. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/file/__init__.py +0 -0
  39. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/file/base.py +0 -0
  40. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/file/path/__init__.py +0 -0
  41. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/file/path/default.py +0 -0
  42. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/__init__.py +0 -0
  43. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/base.py +0 -0
  44. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/feature_types/__init__.py +0 -0
  45. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/feature_types/chunking.py +0 -0
  46. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function/runtime.py +0 -0
  47. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/function_utils.py +0 -0
  48. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/__init__.py +0 -0
  49. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/base.py +0 -0
  50. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/__init__.py +0 -0
  51. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/base.py +0 -0
  52. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/query_api.py +0 -0
  53. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/sf_cli.py +0 -0
  54. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/reader/utils.py +0 -0
  55. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/writer/__init__.py +0 -0
  56. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/writer/base.py +0 -0
  57. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/writer/csv.py +0 -0
  58. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/io/writer/print.py +0 -0
  59. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/__init__.py +0 -0
  60. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/base.py +0 -0
  61. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/default.py +0 -0
  62. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/errors.py +0 -0
  63. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/spark_base.py +0 -0
  64. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/spark_default.py +0 -0
  65. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/__init__.py +0 -0
  66. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/generate_text_request.py +0 -0
  67. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/generate_text_request_builder.py +0 -0
  68. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/generate_text_response.py +0 -0
  69. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway/types/generate_text_response_builder.py +0 -0
  70. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/llm_gateway_config.py +0 -0
  71. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/mixin.py +0 -0
  72. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/__init__.py +0 -0
  73. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/base.py +0 -0
  74. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/default.py +0 -0
  75. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/__init__.py +0 -0
  76. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/auth.py +0 -0
  77. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/credentials.py +0 -0
  78. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/transport.py +0 -0
  79. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/direct/url_resolver.py +0 -0
  80. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/errors.py +0 -0
  81. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/__init__.py +0 -0
  82. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_method.py +0 -0
  83. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_request.py +0 -0
  84. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_request_builder.py +0 -0
  85. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_response.py +0 -0
  86. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential/types/http_response_builder.py +0 -0
  87. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/named_credential_config.py +0 -0
  88. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/py.typed +0 -0
  89. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/scan.py +0 -0
  90. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/spark/__init__.py +0 -0
  91. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/spark/base.py +0 -0
  92. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/spark/column_hints.py +0 -0
  93. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/spark/default.py +0 -0
  94. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/__init__.py +0 -0
  95. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/.devcontainer/devcontainer.json +0 -0
  96. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/Dockerfile.dependencies +0 -0
  97. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/README.md +0 -0
  98. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/build_native_dependencies.sh +0 -0
  99. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/chunking/payload/config.json +0 -0
  100. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/chunking/payload/entrypoint.py +0 -0
  101. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/chunking/requirements.txt +0 -0
  102. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_llm/config.json +0 -0
  103. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_llm/entrypoint.py +0 -0
  104. {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
  105. {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
  106. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_prediction/config.json +0 -0
  107. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/example/chunking_with_prediction/entrypoint.py +0 -0
  108. {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
  109. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/payload/config.json +0 -0
  110. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/payload/entrypoint.py +0 -0
  111. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/payload/utility.py +0 -0
  112. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/requirements-dev.txt +0 -0
  113. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/function/requirements.txt +0 -0
  114. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/.devcontainer/devcontainer.json +0 -0
  115. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/Dockerfile +0 -0
  116. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/Dockerfile.dependencies +0 -0
  117. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/README.md +0 -0
  118. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/account.ipynb +0 -0
  119. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/build_native_dependencies.sh +0 -0
  120. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/examples/employee_hierarchy/employee_data.csv +0 -0
  121. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/examples/employee_hierarchy/entrypoint.py +0 -0
  122. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/examples/streaming_deltas/entrypoint.py +0 -0
  123. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/jupyterlab.sh +0 -0
  124. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/payload/config.json +0 -0
  125. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/payload/entrypoint.py +0 -0
  126. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/requirements-dev.txt +0 -0
  127. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/templates/script/requirements.txt +0 -0
  128. {salesforce_data_customcode-7.0.0rc2 → salesforce_data_customcode-7.0.0rc4}/src/datacustomcode/token_provider.py +0 -0
  129. {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.0rc2
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 Model Object by name with a Spark dataframe
180
- * `write_to_dmo(name, spark_dataframe, write_mode)` – Write to a Data Lake Object by name with a Spark dataframe
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 Model Object by name with a Spark dataframe
155
- * `write_to_dmo(name, spark_dataframe, write_mode)` – Write to a Data Lake Object by name with a Spark dataframe
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.0rc2"
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 endpoint, method, and headers are fixed for the call (taken from
238
- ``request``); only ``body`` varies per row. Use this instead of
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
- return named_credential.request_col(request, body=body)
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
- if reader is None or writer is None:
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