Skip to content

Commit 878eba9

Browse files
committed
Various tests
1 parent 542bf0d commit 878eba9

File tree

5 files changed

+191
-40
lines changed

5 files changed

+191
-40
lines changed

dlt/_workspace/helpers/dashboard/dlt_dashboard.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ def home(
7272
else:
7373
_buttons: List[Any] = []
7474
_buttons.append(dlt_refresh_button)
75-
_pipeline_run_summary: mo.Html = None
76-
_last_load_packages_button: mo.Html = None
75+
_pipeline_execution_summary: mo.Html = None
76+
_last_load_packages_info: mo.Html = None
7777
if dlt_pipeline:
7878
_buttons.append(
7979
mo.ui.button(
@@ -88,14 +88,16 @@ def home(
8888
on_click=lambda _: utils.open_local_folder(local_dir),
8989
)
9090
)
91-
92-
_pipeline_run_summary = utils.build_pipeline_run_visualization(dlt_pipeline.last_trace)
93-
_last_load_packages_button = mo.vstack(
94-
[
95-
mo.md(f"<small>{strings.view_load_packages_text}</small>"),
96-
utils.load_package_status_labels(dlt_pipeline.last_trace),
97-
]
98-
)
91+
if dlt_pipeline.last_trace:
92+
_pipeline_execution_summary = utils.build_pipeline_execution_visualization(
93+
dlt_pipeline.last_trace
94+
)
95+
_last_load_packages_info = mo.vstack(
96+
[
97+
mo.md(f"<small>{strings.view_load_packages_text}</small>"),
98+
utils.load_package_status_labels(dlt_pipeline.last_trace),
99+
]
100+
)
99101
_stack = [
100102
mo.vstack(
101103
[
@@ -112,8 +114,8 @@ def home(
112114
),
113115
]
114116
),
115-
_pipeline_run_summary,
116-
_last_load_packages_button,
117+
_pipeline_execution_summary,
118+
_last_load_packages_info,
117119
mo.hstack(_buttons, justify="start"),
118120
]
119121
if not dlt_pipeline and dlt_pipeline_name:

dlt/_workspace/helpers/dashboard/utils.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,7 @@ def _format_duration(ms: float) -> str:
857857
return f"{round(ms / 6000) / 10}"
858858

859859

860-
def _build_pipeline_run_html(
860+
def _build_pipeline_execution_html(
861861
transaction_id: str,
862862
status: TPipelineRunStatus,
863863
steps_data: List[PipelineStepData],
@@ -909,12 +909,12 @@ def _build_pipeline_run_html(
909909
# Build the migration badge if applicable
910910
migration_badge = f"""
911911
<div style="
912-
background-color: {'var(--yellow-bg)'};
913-
color: {'var(--yellow-text)'};
912+
background-color: var(--yellow-bg);
913+
color: var(--yellow-text);
914914
padding: 6px 16px;
915915
border-radius: 6px;
916916
">
917-
<strong>{f'{migrations_count} dataset migration(s)'}</strong>
917+
<strong>{migrations_count} dataset migration(s)</strong>
918918
</div>
919919
""" if migrations_count > 0 else ""
920920

@@ -1019,14 +1019,13 @@ def _get_migrations_count(last_load_info: LoadInfo) -> int:
10191019
return migrations_count
10201020

10211021

1022-
def build_pipeline_run_visualization(trace: PipelineTrace) -> Optional[mo.Html]:
1022+
def build_pipeline_execution_visualization(trace: PipelineTrace) -> Optional[mo.Html]:
10231023
"""Creates a visual timeline of pipeline run showing extract, normalize and load steps"""
10241024

10251025
steps_data, status = _get_steps_data_and_status(trace.steps)
1026-
10271026
migrations_count = _get_migrations_count(trace.last_load_info) if trace.last_load_info else 0
10281027

1029-
return _build_pipeline_run_html(
1028+
return _build_pipeline_execution_html(
10301029
trace.transaction_id,
10311030
status,
10321031
steps_data,
@@ -1061,11 +1060,6 @@ def build_pipeline_run_visualization(trace: PipelineTrace) -> Optional[mo.Html]:
10611060
)
10621061

10631062

1064-
class TVisualLoadPackageStatusAndSteps(TypedDict):
1065-
package: LoadPackageInfo
1066-
seen_in_steps: List[TVisualPipelineStep]
1067-
1068-
10691063
def _collect_load_packages_from_trace(
10701064
trace: PipelineTrace,
10711065
) -> List[LoadPackageInfo]:

tests/workspace/helpers/dashboard/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
create_success_pipeline_duckdb,
66
create_success_pipeline_filesystem,
77
create_extract_exception_pipeline,
8+
create_normalize_exception_pipeline,
89
create_never_ran_pipeline,
910
create_load_exception_pipeline,
1011
create_no_destination_pipeline,
@@ -43,6 +44,13 @@ def extract_exception_pipeline():
4344
yield create_extract_exception_pipeline(temp_dir)
4445

4546

47+
@pytest.fixture
48+
def normalize_exception_pipeline(temp_pipelines_dir):
49+
"""Fixture that creates a normalize exception pipeline"""
50+
with tempfile.TemporaryDirectory() as temp_dir:
51+
yield create_normalize_exception_pipeline(temp_dir)
52+
53+
4654
@pytest.fixture(scope="session")
4755
def never_ran_pipline():
4856
with tempfile.TemporaryDirectory() as temp_dir:

tests/workspace/helpers/dashboard/example_pipelines.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,26 @@
1818
SUCCESS_PIPELINE_DUCKDB = "success_pipeline_duckdb"
1919
SUCCESS_PIPELINE_FILESYSTEM = "success_pipeline_filesystem"
2020
EXTRACT_EXCEPTION_PIPELINE = "extract_exception_pipeline"
21+
NORMALIZE_EXCEPTION_PIPELINE = "normalize_exception_pipeline"
2122
NEVER_RAN_PIPELINE = "never_ran_pipline"
2223
LOAD_EXCEPTION_PIPELINE = "load_exception_pipeline"
2324
NO_DESTINATION_PIPELINE = "no_destination_pipeline"
2425

2526
ALL_PIPELINES = [
2627
SUCCESS_PIPELINE_DUCKDB,
2728
EXTRACT_EXCEPTION_PIPELINE,
29+
NORMALIZE_EXCEPTION_PIPELINE,
2830
NEVER_RAN_PIPELINE,
2931
LOAD_EXCEPTION_PIPELINE,
3032
NO_DESTINATION_PIPELINE,
3133
SUCCESS_PIPELINE_FILESYSTEM,
3234
]
3335

34-
PIPELINES_WITH_EXCEPTIONS = [EXTRACT_EXCEPTION_PIPELINE, LOAD_EXCEPTION_PIPELINE]
36+
PIPELINES_WITH_EXCEPTIONS = [
37+
EXTRACT_EXCEPTION_PIPELINE,
38+
NORMALIZE_EXCEPTION_PIPELINE,
39+
LOAD_EXCEPTION_PIPELINE,
40+
]
3541
PIPELINES_WITH_LOAD = [SUCCESS_PIPELINE_DUCKDB, SUCCESS_PIPELINE_FILESYSTEM]
3642

3743

@@ -142,6 +148,33 @@ def broken_resource():
142148
return pipeline
143149

144150

151+
def create_normalize_exception_pipeline(pipelines_dir: str = None):
152+
"""Create a test pipeline with duckdb destination, raises an exception in the normalize step"""
153+
import duckdb
154+
155+
pipeline = dlt.pipeline(
156+
pipeline_name=NORMALIZE_EXCEPTION_PIPELINE,
157+
pipelines_dir=pipelines_dir,
158+
destination=dlt.destinations.duckdb(credentials=duckdb.connect(":memory:")),
159+
)
160+
161+
@dlt.resource
162+
def data_with_type_conflict():
163+
# First yield double, then string for same column - causes normalize failure with strict schema contract
164+
yield [{"id": 1, "value": 123.4}]
165+
yield [{"id": 2, "value": "string"}]
166+
167+
with pytest.raises(Exception):
168+
pipeline.run(
169+
data_with_type_conflict(),
170+
schema=dlt.Schema("fruitshop"),
171+
table_name="items",
172+
schema_contract={"data_type": "freeze"}, # Strict mode - fail on type conflicts
173+
)
174+
175+
return pipeline
176+
177+
145178
def create_never_ran_pipeline(pipelines_dir: str = None):
146179
"""Create a test pipeline with duckdb destination which never was run"""
147180
import duckdb
@@ -192,6 +225,7 @@ def create_no_destination_pipeline(pipelines_dir: str = None):
192225
create_success_pipeline_duckdb()
193226
create_success_pipeline_filesystem()
194227
create_extract_exception_pipeline()
228+
create_normalize_exception_pipeline()
195229
create_never_ran_pipeline()
196230
create_load_exception_pipeline()
197231
create_no_destination_pipeline()

tests/workspace/helpers/dashboard/test_utils.py

Lines changed: 128 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from typing import Optional, Set
1+
from typing import cast, Set, List, Dict, Any
22
import os
33
import tempfile
44
from datetime import datetime
55
from pathlib import Path
6+
import re
67

78
import marimo as mo
89
import pyarrow
@@ -45,6 +46,9 @@
4546
get_example_query_for_dataset,
4647
_get_steps_data_and_status,
4748
_get_migrations_count,
49+
build_pipeline_execution_visualization,
50+
_collect_load_packages_from_trace,
51+
load_package_status_labels,
4852
TPipelineRunStatus,
4953
TVisualPipelineStep,
5054
)
@@ -53,6 +57,7 @@
5357
SUCCESS_PIPELINE_DUCKDB,
5458
SUCCESS_PIPELINE_FILESYSTEM,
5559
EXTRACT_EXCEPTION_PIPELINE,
60+
NORMALIZE_EXCEPTION_PIPELINE,
5661
NEVER_RAN_PIPELINE,
5762
LOAD_EXCEPTION_PIPELINE,
5863
NO_DESTINATION_PIPELINE,
@@ -233,7 +238,7 @@ def test_pipeline_details(pipeline, temp_pipelines_dir):
233238
assert isinstance(result, list)
234239
if pipeline.pipeline_name in PIPELINES_WITH_LOAD:
235240
assert len(result) == 9
236-
elif pipeline.pipeline_name == LOAD_EXCEPTION_PIPELINE:
241+
elif pipeline.pipeline_name in [LOAD_EXCEPTION_PIPELINE, NORMALIZE_EXCEPTION_PIPELINE]:
237242
# custom destination does not support remote data info
238243
assert len(result) == 8
239244
else:
@@ -253,10 +258,10 @@ def test_pipeline_details(pipeline, temp_pipelines_dir):
253258
else:
254259
assert details_dict["destination"] == "duckdb (dlt.destinations.duckdb)"
255260
assert details_dict["dataset_name"] == pipeline.dataset_name
256-
if (
257-
pipeline.pipeline_name in PIPELINES_WITH_LOAD
258-
or pipeline.pipeline_name == LOAD_EXCEPTION_PIPELINE
259-
):
261+
if pipeline.pipeline_name in PIPELINES_WITH_LOAD or pipeline.pipeline_name in [
262+
LOAD_EXCEPTION_PIPELINE,
263+
NORMALIZE_EXCEPTION_PIPELINE,
264+
]:
260265
assert details_dict["schemas"].startswith("fruitshop")
261266
else:
262267
assert "schemas" not in details_dict
@@ -502,6 +507,10 @@ def test_trace(pipeline: dlt.Pipeline):
502507
if pipeline.pipeline_name == EXTRACT_EXCEPTION_PIPELINE:
503508
assert len(result) == 1
504509
assert result[0]["step"] == "extract"
510+
elif pipeline.pipeline_name == NORMALIZE_EXCEPTION_PIPELINE:
511+
assert len(result) == 2
512+
assert result[0]["step"] == "extract"
513+
assert result[1]["step"] == "normalize"
505514
else:
506515
assert len(result) == 3
507516
assert result[0]["step"] == "extract"
@@ -781,10 +790,7 @@ def test_get_steps_data_and_status(
781790

782791
@pytest.mark.parametrize(
783792
"pipeline",
784-
[
785-
SUCCESS_PIPELINE_DUCKDB,
786-
SUCCESS_PIPELINE_FILESYSTEM,
787-
],
793+
PIPELINES_WITH_LOAD,
788794
indirect=True,
789795
)
790796
def test_get_migrations_count(pipeline: dlt.Pipeline) -> None:
@@ -794,15 +800,122 @@ def test_get_migrations_count(pipeline: dlt.Pipeline) -> None:
794800
assert migrations_count == 1
795801

796802
# Trigger multiple migrations
797-
pipeline.extract([{"id": 1, "name": "test"}], table_name="migration_table")
798-
pipeline.extract(
799-
[{"id": 2, "name": "test2", "new_column": "value"}], table_name="migration_table"
800-
)
803+
pipeline.extract([{"id": 1, "name": "test"}], table_name="my_table")
804+
pipeline.extract([{"id": 2, "name": "test2", "new_column": "value"}], table_name="my_table")
801805
pipeline.extract(
802806
[{"id": 3, "name": "test3", "new_column": "value", "another_column": 100}],
803-
table_name="migration_table",
807+
table_name="my_table",
804808
)
805809
pipeline.normalize()
806810
pipeline.load()
807811
migrations_count = _get_migrations_count(pipeline.last_trace.last_load_info)
808812
assert migrations_count == 3
813+
814+
815+
@pytest.mark.parametrize(
816+
"pipeline, expected_steps, expected_status",
817+
[
818+
(SUCCESS_PIPELINE_DUCKDB, {"extract", "normalize", "load"}, "succeeded"),
819+
(SUCCESS_PIPELINE_FILESYSTEM, {"extract", "normalize", "load"}, "succeeded"),
820+
(EXTRACT_EXCEPTION_PIPELINE, {"extract"}, "failed"),
821+
(LOAD_EXCEPTION_PIPELINE, {"extract", "normalize", "load"}, "failed"),
822+
],
823+
indirect=["pipeline"],
824+
)
825+
def test_build_pipeline_execution_visualization(
826+
pipeline: dlt.Pipeline,
827+
expected_steps: Set[TVisualPipelineStep],
828+
expected_status: TPipelineRunStatus,
829+
) -> None:
830+
"""Test overall pipeline execution visualization logic"""
831+
832+
trace = pipeline.last_trace
833+
834+
html = build_pipeline_execution_visualization(trace)
835+
html_str = str(html.text)
836+
837+
assert f"Last execution ID: <strong>{trace.transaction_id[:8]}</strong>" in html_str
838+
total_time_match = re.search(
839+
r"<div>Total time: <strong>([\d.]+)(ms|s)?</strong></div>", html_str
840+
)
841+
assert total_time_match is not None
842+
843+
status_badge = f"""
844+
<div style="
845+
background-color: var(--{'green' if expected_status == "succeeded" else 'red'}-bg);
846+
color: var(--{'green' if expected_status == "succeeded" else 'red'}-text);
847+
padding: 6px 16px;
848+
border-radius: 6px;
849+
">
850+
<strong>{expected_status}</strong>
851+
</div>
852+
"""
853+
assert status_badge in html_str
854+
855+
migrations_count = _get_migrations_count(trace.last_load_info) if trace.last_load_info else 0
856+
migration_badge = f"""
857+
<div style="
858+
background-color: var(--yellow-bg);
859+
color: var(--yellow-text);
860+
padding: 6px 16px;
861+
border-radius: 6px;
862+
">
863+
<strong>{migrations_count} dataset migration(s)</strong>
864+
</div>"""
865+
if migrations_count != 0:
866+
assert migration_badge in html_str
867+
else:
868+
assert migration_badge not in html_str
869+
870+
steps_data, _ = _get_steps_data_and_status(trace.steps)
871+
for step_data in steps_data:
872+
duration_pattern = re.search(rf"{step_data.step.capitalize()}\s+([\d.]+)(ms|s)?", html_str)
873+
assert duration_pattern is not None
874+
875+
if "extract" in expected_steps:
876+
assert "var(--dlt-color-lime)" in html_str
877+
if "normalize" in expected_steps:
878+
assert "var(--dlt-color-aqua)" in html_str
879+
if "load" in expected_steps:
880+
assert "var(--dlt-color-pink)" in html_str
881+
882+
883+
@pytest.mark.parametrize(
884+
"pipeline",
885+
[
886+
SUCCESS_PIPELINE_DUCKDB,
887+
SUCCESS_PIPELINE_FILESYSTEM,
888+
EXTRACT_EXCEPTION_PIPELINE,
889+
NORMALIZE_EXCEPTION_PIPELINE,
890+
LOAD_EXCEPTION_PIPELINE,
891+
],
892+
indirect=["pipeline"],
893+
)
894+
def test_collect_load_packages_from_trace(
895+
pipeline: dlt.Pipeline,
896+
) -> None:
897+
"""Test getting load package status labels from trace"""
898+
899+
trace = pipeline.last_trace
900+
table = load_package_status_labels(trace)
901+
902+
list_of_load_package_info = cast(List[Dict[str, Any]], table.data)
903+
904+
if pipeline.pipeline_name in ["success_pipeline_duckdb", "success_pipeline_filesystem"]:
905+
assert len(list_of_load_package_info) == 2
906+
assert all(
907+
"loaded" in str(load_package_info["status"].text)
908+
for load_package_info in list_of_load_package_info
909+
)
910+
911+
elif pipeline.pipeline_name == "extract_exception_pipeline":
912+
assert len(list_of_load_package_info) == 1
913+
assert "new" in str(list_of_load_package_info[0]["status"].text)
914+
915+
elif pipeline.pipeline_name == "load_exception_pipeline":
916+
assert len(list_of_load_package_info) == 1
917+
assert "aborted" in str(list_of_load_package_info[0]["status"].text)
918+
919+
elif pipeline.pipeline_name == "normalize_exception_pipeline":
920+
assert len(list_of_load_package_info) == 1
921+
assert "pending" in str(list_of_load_package_info[0]["status"].text)

0 commit comments

Comments
 (0)