dlt-utils-lib 1.2.7__tar.gz → 1.3.1__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 (21) hide show
  1. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/PKG-INFO +1 -1
  2. dlt_utils_lib-1.3.1/dlt_utils/dlt_table_properties.py +32 -0
  3. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils/main_cdc_utils.py +16 -30
  4. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils/main_json_utils.py +28 -12
  5. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils_lib.egg-info/PKG-INFO +1 -1
  6. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils_lib.egg-info/SOURCES.txt +1 -0
  7. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/tests/test_main_cdc_utils.py +48 -0
  8. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/tests/test_main_json_utils.py +60 -0
  9. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/tests/test_transformation_module.py +10 -2
  10. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils/__init__.py +0 -0
  11. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils/dlt_autoloader_recovery_configuration.py +0 -0
  12. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils/dlt_metadata_receiver.py +0 -0
  13. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils/dlt_transformations.py +0 -0
  14. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils_lib.egg-info/dependency_links.txt +0 -0
  15. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils_lib.egg-info/requires.txt +0 -0
  16. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/dlt_utils_lib.egg-info/top_level.txt +0 -0
  17. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/setup.cfg +0 -0
  18. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/setup.py +0 -0
  19. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/tests/__init__.py +0 -0
  20. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/tests/conftest.py +0 -0
  21. {dlt_utils_lib-1.2.7 → dlt_utils_lib-1.3.1}/tests/test_autoloader_recovery_configuration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dlt_utils_lib
3
- Version: 1.2.7
3
+ Version: 1.3.1
4
4
  Summary: UNKNOWN
5
5
  Home-page: UNKNOWN
6
6
  License: UNKNOWN
@@ -0,0 +1,32 @@
1
+ import json
2
+ from typing import Optional
3
+
4
+ # delta.enableTypeWidening lets Delta accept widened column types (e.g. decimal
5
+ # scale bumps) from upstream schema drift without a manual ALTER + full refresh.
6
+ # Applied to every bronze and silver table so future source-side type changes
7
+ # flow through the pipeline dynamically.
8
+ #
9
+ # Shared by the CDC and JSON replication paths rather than defined in each. They
10
+ # held separate copies once and the copies disagreed: the JSON path never gained
11
+ # type widening, so Kafka-sourced tables needed a full refresh for a change the
12
+ # CDC tables absorbed.
13
+ DEFAULT_TABLE_PROPERTIES = {
14
+ "delta.autoOptimize.optimizeWrite": "true",
15
+ "delta.autoOptimize.autoCompact": "true",
16
+ "delta.enableTypeWidening": "true",
17
+ }
18
+
19
+
20
+ def build_table_properties(extra: Optional[dict] = None,
21
+ column_comments: Optional[dict] = None) -> dict:
22
+ """Defaults, plus column comments when the caller tracks them, plus overrides.
23
+
24
+ `column_comments=None` leaves the key out entirely, which is how bronze
25
+ tables are built; an empty dict still writes "{}" so the property exists.
26
+ """
27
+ props = dict(DEFAULT_TABLE_PROPERTIES)
28
+ if column_comments is not None:
29
+ props["column_comments"] = json.dumps(column_comments) if column_comments else "{}"
30
+ if extra:
31
+ props.update(extra)
32
+ return props
@@ -1,9 +1,9 @@
1
- import json
2
1
  from typing import Optional
3
2
 
4
3
  from pyspark.sql.functions import col, expr, current_timestamp
5
4
 
6
5
  from .dlt_autoloader_recovery_configuration import resolve_autoloader_recovery
6
+ from .dlt_table_properties import build_table_properties
7
7
  from .dlt_transformations import (
8
8
  add_default_value_for_removed_col,
9
9
  apply_partitions,
@@ -12,27 +12,6 @@ from .dlt_transformations import (
12
12
  )
13
13
 
14
14
 
15
- # delta.enableTypeWidening lets Delta accept widened column types (e.g. decimal
16
- # scale bumps) from upstream schema drift without a manual ALTER + full refresh.
17
- # Applied to every bronze and silver table so future source-side type changes
18
- # flow through the pipeline dynamically.
19
- _DEFAULT_TABLE_PROPERTIES = {
20
- "delta.autoOptimize.optimizeWrite": "true",
21
- "delta.autoOptimize.autoCompact": "true",
22
- "delta.enableTypeWidening": "true",
23
- }
24
-
25
-
26
- def _build_table_properties(extra: Optional[dict] = None,
27
- column_comments: Optional[dict] = None) -> dict:
28
- props = dict(_DEFAULT_TABLE_PROPERTIES)
29
- if column_comments is not None:
30
- props["column_comments"] = json.dumps(column_comments) if column_comments else "{}"
31
- if extra:
32
- props.update(extra)
33
- return props
34
-
35
-
36
15
  def _pick_latest_source_file(spark, files_path: str) -> Optional[str]:
37
16
  """Return the path of the most recently modified source file, or None.
38
17
 
@@ -60,22 +39,29 @@ def _pick_latest_source_file(spark, files_path: str) -> Optional[str]:
60
39
  def base_cdc_replication_process(dlt,
61
40
  spark,
62
41
  cdc_tables_map: list,
63
- bronze_directory: str,
64
- bucket_name: str,
42
+ bronze_directory: Optional[str] = None,
43
+ bucket_name: Optional[str] = None,
65
44
  time_diff_history_cdc_timestamp: int = 30,
66
45
  bronze_schema: Optional[str] = None,
67
46
  silver_schema: Optional[str] = None,
68
- use_managed_file_events: bool = False):
47
+ use_managed_file_events: bool = False,
48
+ volume_source_path: Optional[str] = None):
49
+ if volume_source_path is None and (not bronze_directory or not bucket_name):
50
+ raise ValueError("Provide volume_source_path or both bucket_name and bronze_directory")
51
+
69
52
  # parse list with tables for replication
70
53
  for table in cdc_tables_map:
71
54
  source_table_name = table['source_table_name']
72
- source_schema = table['source_schema']
73
55
  file_format = table['format']
74
56
  keys = table['keys']
75
57
  exclude_columns = table['exclude_columns']
76
58
  enable_truncate = table['enable_truncate']
77
- s3_files_path = f"{source_schema}/{source_table_name}/"
78
- bronze_table_path = f"{bucket_name}/{bronze_directory}/{s3_files_path}"
59
+ # The volume path already includes the source schema.
60
+ if volume_source_path is not None:
61
+ bronze_table_path = f"{volume_source_path.rstrip('/')}/{source_table_name}/"
62
+ else:
63
+ source_schema = table['source_schema']
64
+ bronze_table_path = f"{bucket_name}/{bronze_directory}/{source_schema}/{source_table_name}/"
79
65
 
80
66
  # Optional parameters
81
67
  default_value_for_removed_col = table.get('default_value_for_removed_col', {})
@@ -137,7 +123,7 @@ def create_bronze_table_definition(spark,
137
123
  use_managed_file_events: bool,
138
124
  extra_table_properties: Optional[dict] = None,
139
125
  ):
140
- table_properties = _build_table_properties(extra_table_properties)
126
+ table_properties = build_table_properties(extra_table_properties)
141
127
 
142
128
  # Resolve the schemaHints outside the @dlt.table body so that the
143
129
  # collect() inside _pick_latest_source_file runs once at pipeline
@@ -208,7 +194,7 @@ def silver_streaming_process(dlt,
208
194
  cluster_by_value: list = None,
209
195
  is_cluster_by_auto: bool = True,
210
196
  extra_table_properties: Optional[dict] = None):
211
- table_properties = _build_table_properties(
197
+ table_properties = build_table_properties(
212
198
  extra_table_properties,
213
199
  column_comments=column_comments,
214
200
  )
@@ -1,10 +1,10 @@
1
- import json
2
1
  from typing import Callable, Optional
3
2
 
4
3
  from pyspark.sql import DataFrame
5
4
  from pyspark.sql.functions import col, struct
6
5
 
7
6
  from .dlt_autoloader_recovery_configuration import resolve_autoloader_recovery
7
+ from .dlt_table_properties import build_table_properties
8
8
 
9
9
 
10
10
  def base_json_replication_process(
@@ -29,6 +29,7 @@ def base_json_replication_process(
29
29
  is_cluster_by_auto = table.get("is_cluster_by_auto", False)
30
30
  keys = table.get("keys")
31
31
  sequence_by_columns = table.get("sequence_by_columns")
32
+ extra_table_properties = table.get("table_properties") or {}
32
33
 
33
34
  if bronze_schema and silver_schema:
34
35
  bronze_table_name = f"{bronze_schema}.{dataset_name}"
@@ -47,6 +48,7 @@ def base_json_replication_process(
47
48
  schema_hints=schema_hints,
48
49
  partitions=bronze_partitions,
49
50
  use_managed_file_events=use_managed_file_events,
51
+ extra_table_properties=extra_table_properties,
50
52
  )
51
53
 
52
54
  if keys and sequence_by_columns:
@@ -64,6 +66,7 @@ def base_json_replication_process(
64
66
  is_cluster_by_auto=is_cluster_by_auto,
65
67
  keys=keys,
66
68
  sequence_by_columns=sequence_by_columns,
69
+ extra_table_properties=extra_table_properties,
67
70
  )
68
71
  else:
69
72
  create_silver_json_table_definition(
@@ -78,6 +81,7 @@ def base_json_replication_process(
78
81
  column_comments=column_comments,
79
82
  cluster_by_value=cluster_by_value,
80
83
  is_cluster_by_auto=is_cluster_by_auto,
84
+ extra_table_properties=extra_table_properties,
81
85
  )
82
86
 
83
87
 
@@ -87,6 +91,7 @@ def _build_autoloader_reader(
87
91
  file_format: str,
88
92
  schema_hints: Optional[str],
89
93
  use_managed_file_events: bool,
94
+ include_source_file: bool = False,
90
95
  ):
91
96
  reader = (
92
97
  spark.readStream
@@ -108,7 +113,15 @@ def _build_autoloader_reader(
108
113
  .option("cloudFiles.validateOptions", "false")
109
114
  )
110
115
 
111
- return reader.load(source_path)
116
+ loaded = reader.load(source_path)
117
+
118
+ if include_source_file:
119
+ # Which file a row was read from. Off by default because it adds a
120
+ # column, and a column added to an existing bronze table is a schema
121
+ # change for everything already reading it.
122
+ loaded = loaded.withColumn("source_file", col("_metadata.file_path"))
123
+
124
+ return loaded
112
125
 
113
126
 
114
127
  def create_bronze_json_table_definition(
@@ -121,11 +134,14 @@ def create_bronze_json_table_definition(
121
134
  schema_hints: Optional[str],
122
135
  partitions: Optional[list],
123
136
  use_managed_file_events: bool,
137
+ extra_table_properties: Optional[dict] = None,
138
+ include_source_file: bool = False,
124
139
  ):
125
140
  table_kwargs = {
126
141
  "name": table_name,
127
142
  "comment": f"Raw JSON stream data for {dataset_name}.",
128
143
  "temporary": False,
144
+ "table_properties": build_table_properties(extra_table_properties),
129
145
  }
130
146
 
131
147
  if partitions:
@@ -139,6 +155,7 @@ def create_bronze_json_table_definition(
139
155
  file_format=file_format,
140
156
  schema_hints=schema_hints,
141
157
  use_managed_file_events=use_managed_file_events,
158
+ include_source_file=include_source_file,
142
159
  )
143
160
 
144
161
  return transform_json_to_bronze
@@ -156,17 +173,18 @@ def create_silver_json_table_definition(
156
173
  column_comments: Optional[dict] = None,
157
174
  cluster_by_value: Optional[list] = None,
158
175
  is_cluster_by_auto: bool = False,
176
+ extra_table_properties: Optional[dict] = None,
159
177
  ):
160
178
  supports_clustering = not partitions
161
179
  table_kwargs = {
162
180
  "name": table_name,
163
181
  "comment": table_description or f"Structured JSON stream data for {dataset_name}.",
164
182
  "temporary": False,
165
- "table_properties": {
166
- "delta.autoOptimize.optimizeWrite": "true",
167
- "delta.autoOptimize.autoCompact": "true",
168
- "column_comments": json.dumps(column_comments) if column_comments else "{}",
169
- },
183
+ # `or {}` rather than passing None through: the JSON path has always
184
+ # written a column_comments property even when it had none, and dropping
185
+ # the key would change it on every existing silver table. The CDC path
186
+ # omits it instead, which is why the two calls differ here.
187
+ "table_properties": build_table_properties(extra_table_properties, column_comments=column_comments or {}),
170
188
  }
171
189
 
172
190
  if partitions:
@@ -200,6 +218,7 @@ def create_silver_json_cdc_definition(
200
218
  column_comments: Optional[dict] = None,
201
219
  cluster_by_value: Optional[list] = None,
202
220
  is_cluster_by_auto: bool = False,
221
+ extra_table_properties: Optional[dict] = None,
203
222
  ):
204
223
  staging_view_name = f"{dataset_name}_cdc_source"
205
224
  supports_clustering = not partitions
@@ -211,11 +230,8 @@ def create_silver_json_cdc_definition(
211
230
 
212
231
  dlt.create_streaming_table(
213
232
  name=table_name,
214
- table_properties={
215
- "delta.autoOptimize.optimizeWrite": "true",
216
- "delta.autoOptimize.autoCompact": "true",
217
- "column_comments": json.dumps(column_comments) if column_comments else "{}",
218
- },
233
+ # See the note in create_silver_json_table_definition on `or {}`.
234
+ table_properties=build_table_properties(extra_table_properties, column_comments=column_comments or {}),
219
235
  comment=table_description or f"Structured JSON stream data for {dataset_name}.",
220
236
  partition_cols=partitions if partitions else None,
221
237
  cluster_by=cluster_by_value if supports_clustering else None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dlt-utils-lib
3
- Version: 1.2.7
3
+ Version: 1.3.1
4
4
  Summary: UNKNOWN
5
5
  Home-page: UNKNOWN
6
6
  License: UNKNOWN
@@ -2,6 +2,7 @@ setup.py
2
2
  dlt_utils/__init__.py
3
3
  dlt_utils/dlt_autoloader_recovery_configuration.py
4
4
  dlt_utils/dlt_metadata_receiver.py
5
+ dlt_utils/dlt_table_properties.py
5
6
  dlt_utils/dlt_transformations.py
6
7
  dlt_utils/main_cdc_utils.py
7
8
  dlt_utils/main_json_utils.py
@@ -1,3 +1,5 @@
1
+ import pytest
2
+
1
3
  import dlt_utils.main_cdc_utils as main_cdc_utils
2
4
 
3
5
 
@@ -259,3 +261,49 @@ def test_create_bronze_table_definition_falls_back_to_directory_when_listing_emp
259
261
  )
260
262
 
261
263
  assert load_paths == [directory]
264
+
265
+
266
+ @pytest.mark.parametrize("source_options, expected", [
267
+ ({"bucket_name": "s3://bucket", "bronze_directory": "dms_bronze"},
268
+ "s3://bucket/dms_bronze/bifrost/customer/"),
269
+ ({"volume_source_path": "/Volumes/artemis_prod/stage/dms_raw/"},
270
+ "/Volumes/artemis_prod/stage/dms_raw/customer/"),
271
+ ({"volume_source_path": "/Volumes/artemis_prod/stage/dms_raw",
272
+ "bucket_name": "s3://bucket", "bronze_directory": "dms_bronze"},
273
+ "/Volumes/artemis_prod/stage/dms_raw/customer/"),
274
+ ])
275
+ def test_base_cdc_source_paths(monkeypatch, source_options, expected):
276
+ captured = []
277
+ monkeypatch.setattr(main_cdc_utils, "create_bronze_table_definition", lambda **kw: captured.append(kw))
278
+ monkeypatch.setattr(main_cdc_utils, "silver_streaming_process", lambda **kw: None)
279
+ table = _build_cdc_table()
280
+ if "volume_source_path" in source_options:
281
+ del table["source_schema"]
282
+ main_cdc_utils.base_cdc_replication_process(
283
+ dlt=object(), spark=object(), cdc_tables_map=[table],
284
+ **source_options,
285
+ )
286
+ assert captured[0]["files_path"] == expected
287
+
288
+
289
+ @pytest.mark.parametrize("source_options", [
290
+ {},
291
+ {"bucket_name": "s3://bucket"},
292
+ {"bronze_directory": "dms_bronze"},
293
+ ])
294
+ def test_base_cdc_requires_complete_source_configuration(source_options):
295
+ with pytest.raises(ValueError, match="Provide volume_source_path or both bucket_name and bronze_directory"):
296
+ main_cdc_utils.base_cdc_replication_process(
297
+ dlt=object(), spark=object(), cdc_tables_map=[_build_cdc_table()],
298
+ **source_options,
299
+ )
300
+
301
+
302
+ def test_base_cdc_s3_requires_source_schema():
303
+ table = _build_cdc_table()
304
+ del table["source_schema"]
305
+ with pytest.raises(KeyError, match="source_schema"):
306
+ main_cdc_utils.base_cdc_replication_process(
307
+ dlt=object(), spark=object(), cdc_tables_map=[table],
308
+ bucket_name="s3://bucket", bronze_directory="dms_bronze",
309
+ )
@@ -141,3 +141,63 @@ def test_base_json_replication_process_enables_managed_file_events(monkeypatch):
141
141
  )
142
142
 
143
143
  assert captured_bronze_kwargs["use_managed_file_events"] is True
144
+
145
+
146
+ def test_bronze_and_silver_enable_type_widening_by_default():
147
+ dlt_spy = _DltSpy()
148
+
149
+ main_json_utils.base_json_replication_process(
150
+ dlt=dlt_spy,
151
+ spark=object(),
152
+ json_tables_map=[_build_json_table()],
153
+ bronze_schema="bifrost_bronze",
154
+ silver_schema="bifrost_silver",
155
+ )
156
+
157
+ # Bronze carried no table properties at all before this; a Kafka-sourced
158
+ # table needed a full refresh for a widening the CDC tables absorbed.
159
+ for table_kwargs in dlt_spy.decorated_tables:
160
+ properties = table_kwargs["table_properties"]
161
+ assert properties["delta.enableTypeWidening"] == "true"
162
+ assert properties["delta.autoOptimize.optimizeWrite"] == "true"
163
+ assert properties["delta.autoOptimize.autoCompact"] == "true"
164
+
165
+
166
+ def test_cdc_silver_enables_type_widening_by_default(monkeypatch):
167
+ monkeypatch.setattr(main_json_utils, "col", lambda value: value)
168
+ monkeypatch.setattr(main_json_utils, "struct", lambda *values: values)
169
+
170
+ dlt_spy = _DltSpy()
171
+ json_table = dict(_build_json_table(), keys=["id"], sequence_by_columns=["kafka_timestamp"])
172
+
173
+ main_json_utils.base_json_replication_process(
174
+ dlt=dlt_spy,
175
+ spark=object(),
176
+ json_tables_map=[json_table],
177
+ bronze_schema="bifrost_bronze",
178
+ silver_schema="bifrost_silver",
179
+ )
180
+
181
+ assert dlt_spy.streaming_tables[0]["table_properties"]["delta.enableTypeWidening"] == "true"
182
+
183
+
184
+ def test_table_properties_override_the_library_defaults():
185
+ dlt_spy = _DltSpy()
186
+ json_table = dict(
187
+ _build_json_table(),
188
+ table_properties={"delta.enableTypeWidening": "false", "my.custom.prop": "42"},
189
+ )
190
+
191
+ main_json_utils.base_json_replication_process(
192
+ dlt=dlt_spy,
193
+ spark=object(),
194
+ json_tables_map=[json_table],
195
+ bronze_schema="bifrost_bronze",
196
+ silver_schema="bifrost_silver",
197
+ )
198
+
199
+ for table_kwargs in dlt_spy.decorated_tables:
200
+ properties = table_kwargs["table_properties"]
201
+ # Caller override wins over the library default.
202
+ assert properties["delta.enableTypeWidening"] == "false"
203
+ assert properties["my.custom.prop"] == "42"
@@ -7,10 +7,18 @@ from pyspark.sql.functions import expr
7
7
  from pyspark.sql.types import TimestampType, StringType
8
8
  from datetime import datetime
9
9
  from dlt_utils.dlt_transformations import rename_columns, apply_partitions, update_cdc_timestamp, add_default_value_for_removed_col, rename_columns
10
- # Mock databricks.sdk.runtime if not in a Databricks environment
10
+ # Mock databricks.sdk.runtime if not in a Databricks environment.
11
+ #
12
+ # Catching Exception and not ImportError: importing this module builds a
13
+ # Config and tries to authenticate, so off a cluster it fails in whatever way
14
+ # the surrounding credentials happen to be wrong. In CI that is
15
+ # "more than one authorization method configured: oauth and pat", because the
16
+ # project still carries a PAT next to the OAuth variables. On a laptop it is
17
+ # usually several matching profiles in ~/.databrickscfg. Both mean the same
18
+ # thing here, which is that we are not on a cluster and should mock.
11
19
  try:
12
20
  from databricks.sdk.runtime import *
13
- except ImportError:
21
+ except Exception:
14
22
  print("Databricks runtime not available. Mocking required modules.")
15
23
  mock_dbutils = mock.Mock()
16
24
  sys.modules['databricks.sdk.runtime'] = mock.Mock()
File without changes
File without changes