dlt-utils-lib 1.3.0__tar.gz → 1.3.2__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.3.0 → dlt_utils_lib-1.3.2}/PKG-INFO +1 -1
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils/dlt_transformations.py +9 -1
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils/main_cdc_utils.py +15 -6
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils/main_json_utils.py +2 -1
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils_lib.egg-info/PKG-INFO +1 -1
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/tests/test_main_cdc_utils.py +108 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/tests/test_main_json_utils.py +46 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/tests/test_transformation_module.py +24 -1
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils/__init__.py +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils/dlt_autoloader_recovery_configuration.py +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils/dlt_metadata_receiver.py +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils/dlt_table_properties.py +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils_lib.egg-info/SOURCES.txt +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils_lib.egg-info/dependency_links.txt +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils_lib.egg-info/requires.txt +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils_lib.egg-info/top_level.txt +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/setup.cfg +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/setup.py +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/tests/__init__.py +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/tests/conftest.py +0 -0
- {dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/tests/test_autoloader_recovery_configuration.py +0 -0
|
@@ -12,12 +12,20 @@ def apply_partitions(df: DataFrame, partitions: dict):
|
|
|
12
12
|
return df
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
def add_file_landed_timestamp(df: DataFrame) -> DataFrame:
|
|
16
|
+
# When the source file landed in storage (its S3 LastModified), so latency can
|
|
17
|
+
# be measured from there to silver. _metadata exists only on the file source
|
|
18
|
+
# itself, so apply this straight after load().
|
|
19
|
+
return df.withColumn('file_landed_timestamp', col('_metadata.file_modification_time'))
|
|
20
|
+
|
|
21
|
+
|
|
15
22
|
def update_cdc_timestamp(df: DataFrame, time_diff_threshold: int) -> DataFrame:
|
|
16
23
|
# if cdc_timestamp is null or time difference is greater than threshold, set it to the max timestamp in the row
|
|
17
24
|
timestamp_cols = [col.name for col in df.schema.fields if
|
|
18
25
|
isinstance(col.dataType, (TimestampType, DateType)) and col.name not in ['cdc_timestamp',
|
|
19
26
|
'commit_timestamp',
|
|
20
|
-
'dlt_timestamp'
|
|
27
|
+
'dlt_timestamp',
|
|
28
|
+
'file_landed_timestamp']]
|
|
21
29
|
|
|
22
30
|
if timestamp_cols:
|
|
23
31
|
max_timestamp_per_row = None
|
|
@@ -6,6 +6,7 @@ from .dlt_autoloader_recovery_configuration import resolve_autoloader_recovery
|
|
|
6
6
|
from .dlt_table_properties import build_table_properties
|
|
7
7
|
from .dlt_transformations import (
|
|
8
8
|
add_default_value_for_removed_col,
|
|
9
|
+
add_file_landed_timestamp,
|
|
9
10
|
apply_partitions,
|
|
10
11
|
rename_columns,
|
|
11
12
|
update_cdc_timestamp,
|
|
@@ -39,22 +40,29 @@ def _pick_latest_source_file(spark, files_path: str) -> Optional[str]:
|
|
|
39
40
|
def base_cdc_replication_process(dlt,
|
|
40
41
|
spark,
|
|
41
42
|
cdc_tables_map: list,
|
|
42
|
-
bronze_directory: str,
|
|
43
|
-
bucket_name: str,
|
|
43
|
+
bronze_directory: Optional[str] = None,
|
|
44
|
+
bucket_name: Optional[str] = None,
|
|
44
45
|
time_diff_history_cdc_timestamp: int = 30,
|
|
45
46
|
bronze_schema: Optional[str] = None,
|
|
46
47
|
silver_schema: Optional[str] = None,
|
|
47
|
-
use_managed_file_events: bool = False
|
|
48
|
+
use_managed_file_events: bool = False,
|
|
49
|
+
volume_source_path: Optional[str] = None):
|
|
50
|
+
if volume_source_path is None and (not bronze_directory or not bucket_name):
|
|
51
|
+
raise ValueError("Provide volume_source_path or both bucket_name and bronze_directory")
|
|
52
|
+
|
|
48
53
|
# parse list with tables for replication
|
|
49
54
|
for table in cdc_tables_map:
|
|
50
55
|
source_table_name = table['source_table_name']
|
|
51
|
-
source_schema = table['source_schema']
|
|
52
56
|
file_format = table['format']
|
|
53
57
|
keys = table['keys']
|
|
54
58
|
exclude_columns = table['exclude_columns']
|
|
55
59
|
enable_truncate = table['enable_truncate']
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
# The volume path already includes the source schema.
|
|
61
|
+
if volume_source_path is not None:
|
|
62
|
+
bronze_table_path = f"{volume_source_path.rstrip('/')}/{source_table_name}/"
|
|
63
|
+
else:
|
|
64
|
+
source_schema = table['source_schema']
|
|
65
|
+
bronze_table_path = f"{bucket_name}/{bronze_directory}/{source_schema}/{source_table_name}/"
|
|
58
66
|
|
|
59
67
|
# Optional parameters
|
|
60
68
|
default_value_for_removed_col = table.get('default_value_for_removed_col', {})
|
|
@@ -164,6 +172,7 @@ def create_bronze_table_definition(spark,
|
|
|
164
172
|
)
|
|
165
173
|
|
|
166
174
|
return reader.load(files_path) \
|
|
175
|
+
.transform(add_file_landed_timestamp) \
|
|
167
176
|
.withColumn('cdc_timestamp', col('cdc_timestamp').cast('timestamp')) \
|
|
168
177
|
.withColumn('ar_h_change_seq', col('ar_h_change_seq').cast('string')) \
|
|
169
178
|
.withColumn('dlt_timestamp', current_timestamp()) \
|
|
@@ -5,6 +5,7 @@ from pyspark.sql.functions import col, struct
|
|
|
5
5
|
|
|
6
6
|
from .dlt_autoloader_recovery_configuration import resolve_autoloader_recovery
|
|
7
7
|
from .dlt_table_properties import build_table_properties
|
|
8
|
+
from .dlt_transformations import add_file_landed_timestamp
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def base_json_replication_process(
|
|
@@ -113,7 +114,7 @@ def _build_autoloader_reader(
|
|
|
113
114
|
.option("cloudFiles.validateOptions", "false")
|
|
114
115
|
)
|
|
115
116
|
|
|
116
|
-
loaded = reader.load(source_path)
|
|
117
|
+
loaded = reader.load(source_path).transform(add_file_landed_timestamp)
|
|
117
118
|
|
|
118
119
|
if include_source_file:
|
|
119
120
|
# Which file a row was read from. Off by default because it adds a
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
from unittest import mock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
1
5
|
import dlt_utils.main_cdc_utils as main_cdc_utils
|
|
6
|
+
from dlt_utils.dlt_transformations import add_file_landed_timestamp
|
|
2
7
|
|
|
3
8
|
|
|
4
9
|
class _DltSpy:
|
|
@@ -259,3 +264,106 @@ def test_create_bronze_table_definition_falls_back_to_directory_when_listing_emp
|
|
|
259
264
|
)
|
|
260
265
|
|
|
261
266
|
assert load_paths == [directory]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _FakeStream:
|
|
270
|
+
"""Records what the bronze body does to the loaded stream, in order."""
|
|
271
|
+
|
|
272
|
+
def __init__(self):
|
|
273
|
+
self.steps = []
|
|
274
|
+
|
|
275
|
+
def transform(self, fn):
|
|
276
|
+
self.steps.append(fn)
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
def withColumn(self, name, _value):
|
|
280
|
+
self.steps.append(name)
|
|
281
|
+
return self
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class _FakeStreamReader:
|
|
285
|
+
def __init__(self, stream):
|
|
286
|
+
self.stream = stream
|
|
287
|
+
|
|
288
|
+
def format(self, _fmt):
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
def option(self, _key, _value):
|
|
292
|
+
return self
|
|
293
|
+
|
|
294
|
+
def load(self, _path):
|
|
295
|
+
return self.stream
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_bronze_takes_file_landed_timestamp_straight_from_the_source(monkeypatch):
|
|
299
|
+
stream = _FakeStream()
|
|
300
|
+
spark = _make_spark_with_reader([], {})
|
|
301
|
+
spark.readStream = _FakeStreamReader(stream)
|
|
302
|
+
monkeypatch.setattr(main_cdc_utils, "_pick_latest_source_file", lambda *_: None)
|
|
303
|
+
monkeypatch.setattr(main_cdc_utils, "resolve_autoloader_recovery", lambda _spark: False)
|
|
304
|
+
monkeypatch.setattr(main_cdc_utils, "col", lambda _name: mock.MagicMock())
|
|
305
|
+
monkeypatch.setattr(main_cdc_utils, "current_timestamp", lambda: None)
|
|
306
|
+
|
|
307
|
+
bronze = main_cdc_utils.create_bronze_table_definition(
|
|
308
|
+
spark=spark,
|
|
309
|
+
dlt=_DltSpy(),
|
|
310
|
+
source_table_name="settled_bet",
|
|
311
|
+
table_name="artemis_bronze.settled_bet",
|
|
312
|
+
files_path="/Volumes/artemis_prod/stage/dms_raw/settled_bet/",
|
|
313
|
+
file_format="parquet",
|
|
314
|
+
partitions=[],
|
|
315
|
+
schema_exclude_columns=[],
|
|
316
|
+
time_diff_history_cdc_timestamp=30,
|
|
317
|
+
default_value_for_removed_col={},
|
|
318
|
+
use_managed_file_events=True,
|
|
319
|
+
)
|
|
320
|
+
bronze()
|
|
321
|
+
|
|
322
|
+
# _metadata resolves only on the file source, so nothing may come before it.
|
|
323
|
+
assert stream.steps[0] is add_file_landed_timestamp
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@pytest.mark.parametrize("source_options, expected", [
|
|
327
|
+
({"bucket_name": "s3://bucket", "bronze_directory": "dms_bronze"},
|
|
328
|
+
"s3://bucket/dms_bronze/bifrost/customer/"),
|
|
329
|
+
({"volume_source_path": "/Volumes/artemis_prod/stage/dms_raw/"},
|
|
330
|
+
"/Volumes/artemis_prod/stage/dms_raw/customer/"),
|
|
331
|
+
({"volume_source_path": "/Volumes/artemis_prod/stage/dms_raw",
|
|
332
|
+
"bucket_name": "s3://bucket", "bronze_directory": "dms_bronze"},
|
|
333
|
+
"/Volumes/artemis_prod/stage/dms_raw/customer/"),
|
|
334
|
+
])
|
|
335
|
+
def test_base_cdc_source_paths(monkeypatch, source_options, expected):
|
|
336
|
+
captured = []
|
|
337
|
+
monkeypatch.setattr(main_cdc_utils, "create_bronze_table_definition", lambda **kw: captured.append(kw))
|
|
338
|
+
monkeypatch.setattr(main_cdc_utils, "silver_streaming_process", lambda **kw: None)
|
|
339
|
+
table = _build_cdc_table()
|
|
340
|
+
if "volume_source_path" in source_options:
|
|
341
|
+
del table["source_schema"]
|
|
342
|
+
main_cdc_utils.base_cdc_replication_process(
|
|
343
|
+
dlt=object(), spark=object(), cdc_tables_map=[table],
|
|
344
|
+
**source_options,
|
|
345
|
+
)
|
|
346
|
+
assert captured[0]["files_path"] == expected
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@pytest.mark.parametrize("source_options", [
|
|
350
|
+
{},
|
|
351
|
+
{"bucket_name": "s3://bucket"},
|
|
352
|
+
{"bronze_directory": "dms_bronze"},
|
|
353
|
+
])
|
|
354
|
+
def test_base_cdc_requires_complete_source_configuration(source_options):
|
|
355
|
+
with pytest.raises(ValueError, match="Provide volume_source_path or both bucket_name and bronze_directory"):
|
|
356
|
+
main_cdc_utils.base_cdc_replication_process(
|
|
357
|
+
dlt=object(), spark=object(), cdc_tables_map=[_build_cdc_table()],
|
|
358
|
+
**source_options,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_base_cdc_s3_requires_source_schema():
|
|
363
|
+
table = _build_cdc_table()
|
|
364
|
+
del table["source_schema"]
|
|
365
|
+
with pytest.raises(KeyError, match="source_schema"):
|
|
366
|
+
main_cdc_utils.base_cdc_replication_process(
|
|
367
|
+
dlt=object(), spark=object(), cdc_tables_map=[table],
|
|
368
|
+
bucket_name="s3://bucket", bronze_directory="dms_bronze",
|
|
369
|
+
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import dlt_utils.main_json_utils as main_json_utils
|
|
2
|
+
from dlt_utils.dlt_transformations import add_file_landed_timestamp
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
class _DltSpy:
|
|
@@ -201,3 +202,48 @@ def test_table_properties_override_the_library_defaults():
|
|
|
201
202
|
# Caller override wins over the library default.
|
|
202
203
|
assert properties["delta.enableTypeWidening"] == "false"
|
|
203
204
|
assert properties["my.custom.prop"] == "42"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _FakeStream:
|
|
208
|
+
def __init__(self):
|
|
209
|
+
self.steps = []
|
|
210
|
+
|
|
211
|
+
def transform(self, fn):
|
|
212
|
+
self.steps.append(fn)
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
def withColumn(self, name, _value):
|
|
216
|
+
self.steps.append(name)
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _FakeSpark:
|
|
221
|
+
def __init__(self, stream):
|
|
222
|
+
self.readStream = self
|
|
223
|
+
self._stream = stream
|
|
224
|
+
|
|
225
|
+
def format(self, _fmt):
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def option(self, _key, _value):
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def load(self, _path):
|
|
232
|
+
return self._stream
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_bronze_takes_file_landed_timestamp_straight_from_the_source(monkeypatch):
|
|
236
|
+
monkeypatch.setattr(main_json_utils, "resolve_autoloader_recovery", lambda _spark: False)
|
|
237
|
+
monkeypatch.setattr(main_json_utils, "col", lambda value: value)
|
|
238
|
+
stream = _FakeStream()
|
|
239
|
+
|
|
240
|
+
main_json_utils._build_autoloader_reader(
|
|
241
|
+
spark=_FakeSpark(stream),
|
|
242
|
+
source_path="s3://bucket/raw/exposure_state",
|
|
243
|
+
file_format="json",
|
|
244
|
+
schema_hints=None,
|
|
245
|
+
use_managed_file_events=True,
|
|
246
|
+
include_source_file=True,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
assert stream.steps == [add_file_landed_timestamp, "source_file"]
|
|
@@ -6,7 +6,7 @@ from pyspark.sql import SparkSession, Row
|
|
|
6
6
|
from pyspark.sql.functions import expr
|
|
7
7
|
from pyspark.sql.types import TimestampType, StringType
|
|
8
8
|
from datetime import datetime
|
|
9
|
-
from dlt_utils.dlt_transformations import rename_columns, apply_partitions, update_cdc_timestamp, add_default_value_for_removed_col, rename_columns
|
|
9
|
+
from dlt_utils.dlt_transformations import rename_columns, apply_partitions, update_cdc_timestamp, add_default_value_for_removed_col, rename_columns, add_file_landed_timestamp
|
|
10
10
|
# Mock databricks.sdk.runtime if not in a Databricks environment.
|
|
11
11
|
#
|
|
12
12
|
# Catching Exception and not ImportError: importing this module builds a
|
|
@@ -79,6 +79,29 @@ def test_update_cdc_timestamp(spark):
|
|
|
79
79
|
assert result[1]['cdc_timestamp'] == datetime(2024, 1, 1)
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
def test_update_cdc_timestamp_ignores_file_landed_timestamp(spark):
|
|
83
|
+
data = [
|
|
84
|
+
Row(id=1, updated_at=datetime(2024, 1, 10), file_landed_timestamp=datetime(2024, 3, 1), cdc_timestamp=None),
|
|
85
|
+
Row(id=2, updated_at=datetime(2024, 1, 10), file_landed_timestamp=datetime(2024, 3, 1),
|
|
86
|
+
cdc_timestamp=datetime(2024, 1, 9)),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
result = update_cdc_timestamp(spark.createDataFrame(data), time_diff_threshold=5).collect()
|
|
90
|
+
|
|
91
|
+
assert result[0]['cdc_timestamp'] == datetime(2024, 1, 10)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_add_file_landed_timestamp_is_the_file_modification_time(spark, tmp_path):
|
|
95
|
+
path = str(tmp_path / "landed")
|
|
96
|
+
spark.createDataFrame([Row(id=1)]).coalesce(1).write.parquet(path)
|
|
97
|
+
part = next(name for name in os.listdir(path) if name.endswith(".parquet"))
|
|
98
|
+
modified = datetime.fromtimestamp(int(os.path.getmtime(os.path.join(path, part))))
|
|
99
|
+
|
|
100
|
+
result = add_file_landed_timestamp(spark.read.parquet(path)).collect()[0]
|
|
101
|
+
|
|
102
|
+
assert result['file_landed_timestamp'].replace(microsecond=0) == modified
|
|
103
|
+
|
|
104
|
+
|
|
82
105
|
|
|
83
106
|
|
|
84
107
|
def test_add_default_value_for_removed_col(spark):
|
|
File without changes
|
{dlt_utils_lib-1.3.0 → dlt_utils_lib-1.3.2}/dlt_utils/dlt_autoloader_recovery_configuration.py
RENAMED
|
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
|