dlt-utils-lib 1.2.5__tar.gz → 1.2.7__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.
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/PKG-INFO +1 -1
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/dlt_autoloader_recovery_configuration.py +14 -1
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/main_cdc_utils.py +21 -12
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/PKG-INFO +1 -1
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/SOURCES.txt +1 -0
- dlt_utils_lib-1.2.7/tests/test_autoloader_recovery_configuration.py +68 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/test_main_cdc_utils.py +51 -83
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/__init__.py +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/dlt_metadata_receiver.py +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/dlt_transformations.py +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/main_json_utils.py +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/dependency_links.txt +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/requires.txt +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/top_level.txt +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/setup.cfg +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/setup.py +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/__init__.py +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/conftest.py +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/test_main_json_utils.py +0 -0
- {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/test_transformation_module.py +0 -0
{dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/dlt_autoloader_recovery_configuration.py
RENAMED
|
@@ -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
|
|
@@ -139,6 +139,27 @@ def create_bronze_table_definition(spark,
|
|
|
139
139
|
):
|
|
140
140
|
table_properties = _build_table_properties(extra_table_properties)
|
|
141
141
|
|
|
142
|
+
# Resolve the schemaHints outside the @dlt.table body so that the
|
|
143
|
+
# collect() inside _pick_latest_source_file runs once at pipeline
|
|
144
|
+
# setup — Lakeflow Declarative Pipelines forbids eager Spark ops
|
|
145
|
+
# like collect() inside a decorated function.
|
|
146
|
+
#
|
|
147
|
+
# We take the schema from the newest source file so hints track the
|
|
148
|
+
# current source-of-truth type after upstream drift (e.g. DMS
|
|
149
|
+
# widening decimal(19,2) -> decimal(19,3)). Directory reads — with
|
|
150
|
+
# or without mergeSchema — fail for scale-changed decimals because
|
|
151
|
+
# Spark cannot merge them (integer-digit counts differ). Older
|
|
152
|
+
# files with narrower types cast up losslessly under this hint, and
|
|
153
|
+
# delta.enableTypeWidening handles the existing bronze table.
|
|
154
|
+
latest_file = _pick_latest_source_file(spark, files_path)
|
|
155
|
+
schema_source_path = latest_file if latest_file else files_path
|
|
156
|
+
inferred_df = spark.read.format(file_format).load(schema_source_path).transform(rename_columns)
|
|
157
|
+
schema_string = ', '.join([
|
|
158
|
+
f"{field.name} {field.dataType.simpleString()}"
|
|
159
|
+
for field in inferred_df.schema.fields
|
|
160
|
+
if field.name not in schema_exclude_columns
|
|
161
|
+
])
|
|
162
|
+
|
|
142
163
|
@dlt.table(
|
|
143
164
|
name=table_name,
|
|
144
165
|
comment=f"This is the bronze table for table {source_table_name}.",
|
|
@@ -146,18 +167,6 @@ def create_bronze_table_definition(spark,
|
|
|
146
167
|
table_properties=table_properties,
|
|
147
168
|
)
|
|
148
169
|
def transform_cdc_to_bronze():
|
|
149
|
-
# Read the schema from the newest source file so schemaHints tracks
|
|
150
|
-
# the current source-of-truth type after upstream drift (e.g. DMS
|
|
151
|
-
# widening decimal(19,2) -> decimal(19,3)). Directory reads —
|
|
152
|
-
# with or without mergeSchema — fail for scale-changed decimals
|
|
153
|
-
# because Spark can't merge them (integer-digit counts differ).
|
|
154
|
-
# Older files with narrower types cast up losslessly under this
|
|
155
|
-
# hint, and delta.enableTypeWidening handles the bronze table.
|
|
156
|
-
latest_file = _pick_latest_source_file(spark, files_path)
|
|
157
|
-
schema_source_path = latest_file if latest_file else files_path
|
|
158
|
-
df = spark.read.format(file_format).load(schema_source_path).transform(rename_columns)
|
|
159
|
-
fields = [field for field in df.schema.fields if field.name not in schema_exclude_columns]
|
|
160
|
-
schema_string = ', '.join([f"{field.name} {field.dataType.simpleString()}" for field in fields])
|
|
161
170
|
reader = spark \
|
|
162
171
|
.readStream \
|
|
163
172
|
.format('cloudFiles') \
|
|
@@ -12,6 +12,7 @@ dlt_utils_lib.egg-info/requires.txt
|
|
|
12
12
|
dlt_utils_lib.egg-info/top_level.txt
|
|
13
13
|
tests/__init__.py
|
|
14
14
|
tests/conftest.py
|
|
15
|
+
tests/test_autoloader_recovery_configuration.py
|
|
15
16
|
tests/test_main_cdc_utils.py
|
|
16
17
|
tests/test_main_json_utils.py
|
|
17
18
|
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})")
|
|
@@ -33,9 +33,15 @@ def _build_cdc_table():
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _stub_bronze_setup(monkeypatch):
|
|
37
|
+
"""Bypass the setup-time parquet read used to infer schemaHints."""
|
|
38
|
+
monkeypatch.setattr(main_cdc_utils, "_pick_latest_source_file", lambda *_: None)
|
|
39
|
+
|
|
40
|
+
|
|
36
41
|
def test_base_cdc_replication_process_uses_pipeline_schema_configuration(monkeypatch):
|
|
37
42
|
dlt_spy = _DltSpy()
|
|
38
|
-
spark =
|
|
43
|
+
spark = _make_spark_with_reader([], {})
|
|
44
|
+
_stub_bronze_setup(monkeypatch)
|
|
39
45
|
|
|
40
46
|
monkeypatch.setattr(main_cdc_utils, "col", lambda value: value)
|
|
41
47
|
monkeypatch.setattr(main_cdc_utils, "expr", lambda value: value)
|
|
@@ -58,7 +64,8 @@ def test_base_cdc_replication_process_uses_pipeline_schema_configuration(monkeyp
|
|
|
58
64
|
|
|
59
65
|
def test_base_cdc_replication_process_uses_legacy_names_without_both_schemas(monkeypatch):
|
|
60
66
|
dlt_spy = _DltSpy()
|
|
61
|
-
spark =
|
|
67
|
+
spark = _make_spark_with_reader([], {})
|
|
68
|
+
_stub_bronze_setup(monkeypatch)
|
|
62
69
|
|
|
63
70
|
monkeypatch.setattr(main_cdc_utils, "col", lambda value: value)
|
|
64
71
|
monkeypatch.setattr(main_cdc_utils, "expr", lambda value: value)
|
|
@@ -140,52 +147,53 @@ def test_silver_streaming_process_merges_extra_table_properties(monkeypatch):
|
|
|
140
147
|
assert table_properties["my.custom.prop"] == "42"
|
|
141
148
|
|
|
142
149
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
self._t = t
|
|
150
|
+
class _FakeField:
|
|
151
|
+
def __init__(self, name, type_str):
|
|
152
|
+
self.name = name
|
|
147
153
|
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
class _DT:
|
|
155
|
+
def simpleString(self_inner):
|
|
156
|
+
return type_str
|
|
150
157
|
|
|
151
|
-
|
|
152
|
-
def __init__(self, name, type_str):
|
|
153
|
-
self.name = name
|
|
154
|
-
self.dataType = _DT(type_str)
|
|
158
|
+
self.dataType = _DT()
|
|
155
159
|
|
|
156
|
-
class _DF:
|
|
157
|
-
def __init__(self, fields):
|
|
158
|
-
class _Schema:
|
|
159
|
-
pass
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
class _FakeDF:
|
|
162
|
+
def __init__(self, fields):
|
|
163
|
+
class _Schema:
|
|
164
|
+
pass
|
|
163
165
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
self.schema = _Schema()
|
|
167
|
+
self.schema.fields = fields
|
|
166
168
|
|
|
167
|
-
|
|
169
|
+
def transform(self, _fn):
|
|
170
|
+
return self
|
|
168
171
|
|
|
172
|
+
|
|
173
|
+
def _make_spark_with_reader(load_paths, fields_by_path):
|
|
169
174
|
class _Reader:
|
|
170
|
-
def format(self,
|
|
171
|
-
calls["format"] = fmt
|
|
175
|
+
def format(self, _fmt):
|
|
172
176
|
return self
|
|
173
177
|
|
|
174
178
|
def load(self, path):
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
# source-of-truth after upstream drift.
|
|
178
|
-
return _DF([_Field("pnl", "decimal(19,3)"), _Field("id", "bigint")])
|
|
179
|
+
load_paths.append(path)
|
|
180
|
+
return _FakeDF(fields_by_path.get(path, []))
|
|
179
181
|
|
|
180
182
|
spark = type("_Spark", (), {})()
|
|
181
183
|
spark.read = _Reader()
|
|
184
|
+
return spark
|
|
185
|
+
|
|
182
186
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
def test_create_bronze_table_definition_reads_schema_from_latest_file_at_setup_time(monkeypatch):
|
|
188
|
+
load_paths = []
|
|
189
|
+
latest_path = "s3://bucket/dms_bronze/artemis/settled_bet/20260701-090901614.parquet"
|
|
190
|
+
spark = _make_spark_with_reader(
|
|
191
|
+
load_paths,
|
|
192
|
+
{latest_path: [_FakeField("pnl", "decimal(19,3)"), _FakeField("id", "bigint")]},
|
|
187
193
|
)
|
|
188
194
|
|
|
195
|
+
monkeypatch.setattr(main_cdc_utils, "_pick_latest_source_file", lambda *_: latest_path)
|
|
196
|
+
|
|
189
197
|
captured = {}
|
|
190
198
|
|
|
191
199
|
class _Dlt:
|
|
@@ -212,61 +220,26 @@ def test_create_bronze_table_definition_reads_schema_from_latest_file_and_enable
|
|
|
212
220
|
use_managed_file_events=False,
|
|
213
221
|
)
|
|
214
222
|
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
|
|
219
|
-
captured["fn"]()
|
|
220
|
-
except AttributeError:
|
|
221
|
-
pass
|
|
222
|
-
|
|
223
|
-
assert calls["format"] == "parquet"
|
|
224
|
-
assert calls["load_paths"] == [
|
|
225
|
-
"s3://bucket/dms_bronze/artemis/settled_bet/20260701-090901614.parquet"
|
|
226
|
-
]
|
|
223
|
+
# The schema-inference read must happen at setup time, BEFORE the
|
|
224
|
+
# @dlt.table body ever runs. That means load_paths already contains
|
|
225
|
+
# the latest file just from calling create_bronze_table_definition.
|
|
226
|
+
assert load_paths == [latest_path]
|
|
227
227
|
assert captured["kwargs"]["table_properties"]["delta.enableTypeWidening"] == "true"
|
|
228
228
|
|
|
229
229
|
|
|
230
|
-
def
|
|
231
|
-
class _DT:
|
|
232
|
-
def simpleString(self):
|
|
233
|
-
return "bigint"
|
|
234
|
-
|
|
235
|
-
class _Field:
|
|
236
|
-
def __init__(self, name):
|
|
237
|
-
self.name = name
|
|
238
|
-
self.dataType = _DT()
|
|
239
|
-
|
|
240
|
-
class _DF:
|
|
241
|
-
def __init__(self):
|
|
242
|
-
class _Schema:
|
|
243
|
-
pass
|
|
244
|
-
|
|
245
|
-
self.schema = _Schema()
|
|
246
|
-
self.schema.fields = [_Field("id")]
|
|
247
|
-
|
|
248
|
-
def transform(self, _fn):
|
|
249
|
-
return self
|
|
250
|
-
|
|
230
|
+
def test_create_bronze_table_definition_falls_back_to_directory_when_listing_empty(monkeypatch):
|
|
251
231
|
load_paths = []
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def load(self, path):
|
|
258
|
-
load_paths.append(path)
|
|
259
|
-
return _DF()
|
|
260
|
-
|
|
261
|
-
spark = type("_Spark", (), {})()
|
|
262
|
-
spark.read = _Reader()
|
|
232
|
+
directory = "s3://bucket/dms_bronze/artemis/settled_bet/"
|
|
233
|
+
spark = _make_spark_with_reader(
|
|
234
|
+
load_paths,
|
|
235
|
+
{directory: [_FakeField("id", "bigint")]},
|
|
236
|
+
)
|
|
263
237
|
|
|
264
238
|
monkeypatch.setattr(main_cdc_utils, "_pick_latest_source_file", lambda *_: None)
|
|
265
239
|
|
|
266
240
|
class _Dlt:
|
|
267
241
|
def table(self, **_kwargs):
|
|
268
242
|
def _decorator(fn):
|
|
269
|
-
_Dlt.fn = staticmethod(fn)
|
|
270
243
|
return fn
|
|
271
244
|
|
|
272
245
|
return _decorator
|
|
@@ -276,7 +249,7 @@ def test_create_bronze_table_definition_falls_back_to_directory_read_when_listin
|
|
|
276
249
|
dlt=_Dlt(),
|
|
277
250
|
source_table_name="settled_bet",
|
|
278
251
|
table_name="apollo.artemis.bronze_settled_bet",
|
|
279
|
-
files_path=
|
|
252
|
+
files_path=directory,
|
|
280
253
|
file_format="parquet",
|
|
281
254
|
partitions=[],
|
|
282
255
|
schema_exclude_columns=[],
|
|
@@ -285,9 +258,4 @@ def test_create_bronze_table_definition_falls_back_to_directory_read_when_listin
|
|
|
285
258
|
use_managed_file_events=False,
|
|
286
259
|
)
|
|
287
260
|
|
|
288
|
-
|
|
289
|
-
_Dlt.fn()
|
|
290
|
-
except AttributeError:
|
|
291
|
-
pass
|
|
292
|
-
|
|
293
|
-
assert load_paths == ["s3://bucket/dms_bronze/artemis/settled_bet/"]
|
|
261
|
+
assert load_paths == [directory]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|