dlt-utils-lib 1.2.6__tar.gz → 1.3.0__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.6 → dlt_utils_lib-1.3.0}/PKG-INFO +1 -1
  2. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils/dlt_autoloader_recovery_configuration.py +14 -1
  3. dlt_utils_lib-1.3.0/dlt_utils/dlt_table_properties.py +32 -0
  4. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils/main_cdc_utils.py +3 -24
  5. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils/main_json_utils.py +28 -12
  6. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils_lib.egg-info/PKG-INFO +1 -1
  7. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils_lib.egg-info/SOURCES.txt +2 -0
  8. dlt_utils_lib-1.3.0/tests/test_autoloader_recovery_configuration.py +68 -0
  9. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/tests/test_main_json_utils.py +60 -0
  10. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/tests/test_transformation_module.py +10 -2
  11. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils/__init__.py +0 -0
  12. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils/dlt_metadata_receiver.py +0 -0
  13. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils/dlt_transformations.py +0 -0
  14. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils_lib.egg-info/dependency_links.txt +0 -0
  15. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils_lib.egg-info/requires.txt +0 -0
  16. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/dlt_utils_lib.egg-info/top_level.txt +0 -0
  17. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/setup.cfg +0 -0
  18. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/setup.py +0 -0
  19. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/tests/__init__.py +0 -0
  20. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/tests/conftest.py +0 -0
  21. {dlt_utils_lib-1.2.6 → dlt_utils_lib-1.3.0}/tests/test_main_cdc_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dlt_utils_lib
3
- Version: 1.2.6
3
+ Version: 1.3.0
4
4
  Summary: UNKNOWN
5
5
  Home-page: UNKNOWN
6
6
  License: UNKNOWN
@@ -2,6 +2,8 @@ import logging
2
2
 
3
3
  from databricks.sdk import WorkspaceClient
4
4
 
5
+ _RECOVERY_DECISION = None
6
+
5
7
 
6
8
  def _needs_autoloader_recovery(pipeline_id: str) -> bool:
7
9
  event = next(iter(WorkspaceClient().pipelines.list_pipeline_events(
@@ -18,9 +20,20 @@ def _needs_autoloader_recovery(pipeline_id: str) -> bool:
18
20
 
19
21
 
20
22
  def resolve_autoloader_recovery(spark) -> bool:
23
+ # Cached for the lifetime of the driver process: the decision belongs to the
24
+ # update as a whole, but callers ask once per table while the pipeline graph
25
+ # is built. Uncached, a 94-table pipeline makes 94 Pipelines API calls,
26
+ # serialized on the driver, before a single stream starts.
27
+ global _RECOVERY_DECISION
28
+ if _RECOVERY_DECISION is None:
29
+ _RECOVERY_DECISION = _resolve_autoloader_recovery(spark)
30
+ return _RECOVERY_DECISION
31
+
32
+
33
+ def _resolve_autoloader_recovery(spark) -> bool:
21
34
  method = spark.conf.get("autoloader_recovery_method", "AUTO").upper()
22
35
  if method == "ON":
23
36
  return True
24
37
  if method == "AUTO":
25
38
  return _needs_autoloader_recovery(spark.conf.get("pipelines.id"))
26
- return False
39
+ return False
@@ -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
 
@@ -137,7 +116,7 @@ def create_bronze_table_definition(spark,
137
116
  use_managed_file_events: bool,
138
117
  extra_table_properties: Optional[dict] = None,
139
118
  ):
140
- table_properties = _build_table_properties(extra_table_properties)
119
+ table_properties = build_table_properties(extra_table_properties)
141
120
 
142
121
  # Resolve the schemaHints outside the @dlt.table body so that the
143
122
  # collect() inside _pick_latest_source_file runs once at pipeline
@@ -208,7 +187,7 @@ def silver_streaming_process(dlt,
208
187
  cluster_by_value: list = None,
209
188
  is_cluster_by_auto: bool = True,
210
189
  extra_table_properties: Optional[dict] = None):
211
- table_properties = _build_table_properties(
190
+ table_properties = build_table_properties(
212
191
  extra_table_properties,
213
192
  column_comments=column_comments,
214
193
  )
@@ -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.6
3
+ Version: 1.3.0
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
@@ -12,6 +13,7 @@ dlt_utils_lib.egg-info/requires.txt
12
13
  dlt_utils_lib.egg-info/top_level.txt
13
14
  tests/__init__.py
14
15
  tests/conftest.py
16
+ tests/test_autoloader_recovery_configuration.py
15
17
  tests/test_main_cdc_utils.py
16
18
  tests/test_main_json_utils.py
17
19
  tests/test_transformation_module.py
@@ -0,0 +1,68 @@
1
+ import pytest
2
+
3
+ import dlt_utils.dlt_autoloader_recovery_configuration as recovery
4
+
5
+
6
+ class _SparkConfStub:
7
+ def __init__(self, values):
8
+ self._values = values
9
+
10
+ def get(self, key, default=None):
11
+ return self._values.get(key, default)
12
+
13
+
14
+ class _SparkStub:
15
+ def __init__(self, **values):
16
+ self.conf = _SparkConfStub(values)
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def reset_cached_decision():
21
+ recovery._RECOVERY_DECISION = None
22
+ yield
23
+ recovery._RECOVERY_DECISION = None
24
+
25
+
26
+ def test_method_on_forces_recovery_without_calling_the_api(monkeypatch):
27
+ monkeypatch.setattr(recovery, "_needs_autoloader_recovery", _unexpected_api_call)
28
+
29
+ assert recovery.resolve_autoloader_recovery(
30
+ _SparkStub(autoloader_recovery_method="ON")
31
+ ) is True
32
+
33
+
34
+ def test_method_off_skips_recovery_without_calling_the_api(monkeypatch):
35
+ monkeypatch.setattr(recovery, "_needs_autoloader_recovery", _unexpected_api_call)
36
+
37
+ assert recovery.resolve_autoloader_recovery(
38
+ _SparkStub(autoloader_recovery_method="OFF")
39
+ ) is False
40
+
41
+
42
+ def test_method_auto_asks_the_pipelines_api(monkeypatch):
43
+ seen = []
44
+ monkeypatch.setattr(recovery, "_needs_autoloader_recovery",
45
+ lambda pipeline_id: seen.append(pipeline_id) or True)
46
+
47
+ spark = _SparkStub(autoloader_recovery_method="AUTO", **{"pipelines.id": "pipeline-1"})
48
+
49
+ assert recovery.resolve_autoloader_recovery(spark) is True
50
+ assert seen == ["pipeline-1"]
51
+
52
+
53
+ def test_decision_is_resolved_once_per_process(monkeypatch):
54
+ calls = []
55
+ monkeypatch.setattr(recovery, "_needs_autoloader_recovery",
56
+ lambda pipeline_id: calls.append(pipeline_id) or True)
57
+
58
+ spark = _SparkStub(autoloader_recovery_method="AUTO", **{"pipelines.id": "pipeline-1"})
59
+
60
+ # One call per table while the DLT graph is built; only the first may hit the API.
61
+ for _ in range(10):
62
+ assert recovery.resolve_autoloader_recovery(spark) is True
63
+
64
+ assert calls == ["pipeline-1"]
65
+
66
+
67
+ def _unexpected_api_call(pipeline_id):
68
+ raise AssertionError(f"Pipelines API should not be called (pipeline_id={pipeline_id})")
@@ -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