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.
Files changed (20) hide show
  1. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/PKG-INFO +1 -1
  2. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/dlt_autoloader_recovery_configuration.py +14 -1
  3. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/main_cdc_utils.py +21 -12
  4. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/PKG-INFO +1 -1
  5. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/SOURCES.txt +1 -0
  6. dlt_utils_lib-1.2.7/tests/test_autoloader_recovery_configuration.py +68 -0
  7. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/test_main_cdc_utils.py +51 -83
  8. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/__init__.py +0 -0
  9. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/dlt_metadata_receiver.py +0 -0
  10. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/dlt_transformations.py +0 -0
  11. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils/main_json_utils.py +0 -0
  12. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/dependency_links.txt +0 -0
  13. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/requires.txt +0 -0
  14. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/dlt_utils_lib.egg-info/top_level.txt +0 -0
  15. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/setup.cfg +0 -0
  16. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/setup.py +0 -0
  17. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/__init__.py +0 -0
  18. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/conftest.py +0 -0
  19. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/test_main_json_utils.py +0 -0
  20. {dlt_utils_lib-1.2.5 → dlt_utils_lib-1.2.7}/tests/test_transformation_module.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dlt_utils_lib
3
- Version: 1.2.5
3
+ Version: 1.2.7
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
@@ -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') \
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dlt-utils-lib
3
- Version: 1.2.5
3
+ Version: 1.2.7
4
4
  Summary: UNKNOWN
5
5
  Home-page: UNKNOWN
6
6
  License: UNKNOWN
@@ -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 = object()
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 = object()
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
- def test_create_bronze_table_definition_reads_schema_from_latest_file_and_enables_type_widening(monkeypatch):
144
- class _DT:
145
- def __init__(self, t):
146
- self._t = t
150
+ class _FakeField:
151
+ def __init__(self, name, type_str):
152
+ self.name = name
147
153
 
148
- def simpleString(self):
149
- return self._t
154
+ class _DT:
155
+ def simpleString(self_inner):
156
+ return type_str
150
157
 
151
- class _Field:
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
- self.schema = _Schema()
162
- self.schema.fields = fields
161
+ class _FakeDF:
162
+ def __init__(self, fields):
163
+ class _Schema:
164
+ pass
163
165
 
164
- def transform(self, _fn):
165
- return self
166
+ self.schema = _Schema()
167
+ self.schema.fields = fields
166
168
 
167
- calls = {"load_paths": [], "format": None}
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, fmt):
171
- calls["format"] = fmt
175
+ def format(self, _fmt):
172
176
  return self
173
177
 
174
178
  def load(self, path):
175
- calls["load_paths"].append(path)
176
- # The picked "latest" file reports decimal(19,3), the current
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
- monkeypatch.setattr(
184
- main_cdc_utils,
185
- "_pick_latest_source_file",
186
- lambda _spark, _files_path: "s3://bucket/dms_bronze/artemis/settled_bet/20260701-090901614.parquet",
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
- # dlt.table just captures the transform — invoke it to exercise the
216
- # initial spark.read path (the readStream chain that follows is not
217
- # exercised here; the AttributeError it raises is expected).
218
- try:
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 test_create_bronze_table_definition_falls_back_to_directory_read_when_listing_empty(monkeypatch):
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
- class _Reader:
254
- def format(self, _fmt):
255
- return self
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="s3://bucket/dms_bronze/artemis/settled_bet/",
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
- try:
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