Skip to content

Commit 1575f9f

Browse files
authored
Read display name from dashboard.yml (#144)
1 parent ffb9bd5 commit 1575f9f

File tree

5 files changed

+186
-51
lines changed

5 files changed

+186
-51
lines changed

docs/dashboards.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,12 @@ write.
197197
## `dashboard.yml` file
198198

199199
The `dashboard.yml` file is used to define a top-level metadata for the dashboard, such as the display name, warehouse,
200-
and the list of tile overrides for cases, that cannot be handled with the [high-level metadata](#metadata) in the SQL files.
200+
and the list of tile overrides for cases, that cannot be handled with the [high-level metadata](#metadata) in the SQL
201+
files. The file requires the `display_name` field, other fields are optional. See below for the configuration schema:
202+
203+
```yml
204+
display_name: <display name>
205+
```
201206
202207
This file may contain extra information about the [widgets](#widget-types), but we aim at mostly [inferring it](#implicit-detection) from the SQL files.
203208

src/databricks/labs/lsql/dashboards.py

+49-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import dataclasses
22
import json
33
import logging
4+
from dataclasses import dataclass
45
from pathlib import Path
56
from typing import TypeVar
67

@@ -30,6 +31,20 @@
3031
logger = logging.getLogger(__name__)
3132

3233

34+
@dataclass
35+
class DashboardMetadata:
36+
display_name: str
37+
38+
@classmethod
39+
def from_dict(cls, raw: dict[str, str]) -> "DashboardMetadata":
40+
return cls(
41+
display_name=raw["display_name"],
42+
)
43+
44+
def as_dict(self) -> dict[str, str]:
45+
return dataclasses.asdict(self)
46+
47+
3348
class Dashboards:
3449
_MAXIMUM_DASHBOARD_WIDTH = 6
3550

@@ -78,7 +93,9 @@ def _format_query(query: str) -> str:
7893
return formatted_query
7994

8095
def create_dashboard(self, dashboard_folder: Path) -> Dashboard:
81-
"""Create a dashboard from code, i.e. configuration and queries."""
96+
"""Create a dashboard from code, i.e. metadata and queries."""
97+
dashboard_metadata = self._parse_dashboard_metadata(dashboard_folder)
98+
8299
position = Position(0, 0, 0, 0) # First widget position
83100
datasets, layouts = [], []
84101
for query_path in sorted(dashboard_folder.glob("*.sql")):
@@ -106,10 +123,33 @@ def create_dashboard(self, dashboard_folder: Path) -> Dashboard:
106123
layout = Layout(widget=widget, position=position)
107124
layouts.append(layout)
108125

109-
page = Page(name=dashboard_folder.name, display_name=dashboard_folder.name, layout=layouts)
126+
page = Page(
127+
name=dashboard_metadata.display_name,
128+
display_name=dashboard_metadata.display_name,
129+
layout=layouts,
130+
)
110131
lakeview_dashboard = Dashboard(datasets=datasets, pages=[page])
111132
return lakeview_dashboard
112133

134+
@staticmethod
135+
def _parse_dashboard_metadata(dashboard_folder: Path) -> DashboardMetadata:
136+
fallback_metadata = DashboardMetadata(display_name=dashboard_folder.name)
137+
138+
dashboard_metadata_path = dashboard_folder / "dashboard.yml"
139+
if not dashboard_metadata_path.exists():
140+
return fallback_metadata
141+
142+
try:
143+
raw = yaml.safe_load(dashboard_metadata_path.read_text())
144+
except yaml.YAMLError as e:
145+
logger.warning(f"Parsing {dashboard_metadata_path}: {e}")
146+
return fallback_metadata
147+
try:
148+
return DashboardMetadata.from_dict(raw)
149+
except KeyError as e:
150+
logger.warning(f"Parsing {dashboard_metadata_path}: {e}")
151+
return fallback_metadata
152+
113153
@staticmethod
114154
def _get_text_widget(path: Path) -> Widget:
115155
widget = Widget(name=path.stem, textbox_spec=path.read_text())
@@ -166,21 +206,17 @@ def _get_width_and_height(self, widget: Widget) -> tuple[int, int]:
166206
raise NotImplementedError(f"No width defined for spec: {widget}")
167207
return width, height
168208

169-
def deploy_dashboard(
170-
self, lakeview_dashboard: Dashboard, *, display_name: str | None = None, dashboard_id: str | None = None
171-
) -> SDKDashboard:
209+
def deploy_dashboard(self, lakeview_dashboard: Dashboard, *, dashboard_id: str | None = None) -> SDKDashboard:
172210
"""Deploy a lakeview dashboard."""
173-
if (display_name is None and dashboard_id is None) or (display_name is not None and dashboard_id is not None):
174-
raise ValueError("Give either display_name or dashboard_id.")
175-
if display_name is not None:
176-
dashboard = self._ws.lakeview.create(
177-
display_name, serialized_dashboard=json.dumps(lakeview_dashboard.as_dict())
178-
)
179-
else:
180-
assert dashboard_id is not None
211+
if dashboard_id is not None:
181212
dashboard = self._ws.lakeview.update(
182213
dashboard_id, serialized_dashboard=json.dumps(lakeview_dashboard.as_dict())
183214
)
215+
else:
216+
display_name = lakeview_dashboard.pages[0].display_name or lakeview_dashboard.pages[0].name
217+
dashboard = self._ws.lakeview.create(
218+
display_name, serialized_dashboard=json.dumps(lakeview_dashboard.as_dict())
219+
)
184220
return dashboard
185221

186222
def _with_better_names(self, dashboard: Dashboard) -> Dashboard:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
display_name: Counter

tests/integration/test_dashboards.py

+74-16
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,123 @@
11
import json
2+
import logging
23
from pathlib import Path
34

45
import pytest
6+
from databricks.sdk.core import DatabricksError
7+
from databricks.sdk.service.dashboards import Dashboard as SDKDashboard
58

69
from databricks.labs.lsql.dashboards import Dashboards
710
from databricks.labs.lsql.lakeview.model import Dashboard
811

12+
logger = logging.getLogger(__name__)
13+
14+
15+
def factory(name, create, remove):
16+
cleanup = []
17+
18+
def inner(**kwargs):
19+
x = create(**kwargs)
20+
logger.debug(f"added {name} fixture: {x}")
21+
cleanup.append(x)
22+
return x
23+
24+
yield inner
25+
logger.debug(f"clearing {len(cleanup)} {name} fixtures")
26+
for x in cleanup:
27+
try:
28+
logger.debug(f"removing {name} fixture: {x}")
29+
remove(x)
30+
except DatabricksError as e:
31+
# TODO: fix on the databricks-labs-pytester level
32+
logger.debug(f"ignoring error while {name} {x} teardown: {e}")
33+
934

1035
@pytest.fixture
11-
def dashboard_id(ws, make_random):
36+
def make_dashboard(ws, make_random):
1237
"""Clean the lakeview dashboard"""
1338

14-
dashboard_display_name = f"created_by_lsql_{make_random()}"
15-
dashboard = ws.lakeview.create(dashboard_display_name)
39+
def create(display_name: str = "") -> SDKDashboard:
40+
if len(display_name) == 0:
41+
display_name = f"created_by_lsql_{make_random()}"
42+
else:
43+
display_name = f"{display_name} ({make_random()})"
44+
dashboard = ws.lakeview.create(display_name)
45+
return dashboard
46+
47+
def delete(dashboard: SDKDashboard) -> None:
48+
ws.lakeview.trash(dashboard.dashboard_id)
1649

17-
yield dashboard.dashboard_id
50+
yield from factory("dashboard", create, delete)
1851

19-
ws.lakeview.trash(dashboard.dashboard_id)
2052

53+
def test_dashboards_deploys_exported_dashboard_definition(ws, make_dashboard):
54+
sdk_dashboard = make_dashboard()
2155

22-
def test_dashboards_deploys_exported_dashboard_definition(ws, dashboard_id):
2356
dashboard_file = Path(__file__).parent / "dashboards" / "dashboard.json"
2457
with dashboard_file.open("r") as f:
2558
lakeview_dashboard = Dashboard.from_dict(json.load(f))
2659

2760
dashboards = Dashboards(ws)
28-
dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=dashboard_id)
61+
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=sdk_dashboard.dashboard_id)
62+
63+
assert ws.lakeview.get(sdk_dashboard.dashboard_id)
2964

30-
assert ws.lakeview.get(dashboard.dashboard_id)
3165

66+
def test_dashboard_deploys_dashboard_the_same_as_created_dashboard(ws, make_dashboard, tmp_path):
67+
sdk_dashboard = make_dashboard()
3268

33-
def test_dashboard_deploys_dashboard_the_same_as_created_dashboard(tmp_path, ws, dashboard_id):
3469
with (tmp_path / "counter.sql").open("w") as f:
3570
f.write("SELECT 10 AS count")
3671
dashboards = Dashboards(ws)
37-
dashboard = dashboards.create_dashboard(tmp_path)
72+
lakeview_dashboard = dashboards.create_dashboard(tmp_path)
3873

39-
sdk_dashboard = dashboards.deploy_dashboard(dashboard, dashboard_id=dashboard_id)
74+
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=sdk_dashboard.dashboard_id)
4075
new_dashboard = dashboards.get_dashboard(sdk_dashboard.path)
4176

42-
assert dashboards._with_better_names(dashboard).as_dict() == dashboards._with_better_names(new_dashboard).as_dict()
77+
assert (
78+
dashboards._with_better_names(lakeview_dashboard).as_dict()
79+
== dashboards._with_better_names(new_dashboard).as_dict()
80+
)
4381

4482

45-
def test_dashboard_deploys_dashboard_with_ten_counters(ws, dashboard_id, tmp_path):
83+
def test_dashboard_deploys_dashboard_with_ten_counters(ws, make_dashboard, tmp_path):
84+
sdk_dashboard = make_dashboard()
85+
4686
for i in range(10):
4787
with (tmp_path / f"counter_{i}.sql").open("w") as f:
4888
f.write(f"SELECT {i} AS count")
4989
dashboards = Dashboards(ws)
5090
lakeview_dashboard = dashboards.create_dashboard(tmp_path)
5191

52-
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=dashboard_id)
92+
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=sdk_dashboard.dashboard_id)
5393

5494
assert ws.lakeview.get(sdk_dashboard.dashboard_id)
5595

5696

57-
def test_dashboard_deploys_dashboard_with_counter_variation(ws, dashboard_id, tmp_path):
97+
def test_dashboard_deploys_dashboard_with_display_name(ws, make_dashboard, tmp_path):
98+
sdk_dashboard = make_dashboard(display_name="Counter")
99+
100+
with (tmp_path / "dashboard.yml").open("w") as f:
101+
f.write("display_name: Counter")
102+
with (tmp_path / "counter.sql").open("w") as f:
103+
f.write("SELECT 102132 AS count")
104+
105+
dashboards = Dashboards(ws)
106+
lakeview_dashboard = dashboards.create_dashboard(tmp_path)
107+
108+
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=sdk_dashboard.dashboard_id)
109+
110+
assert ws.lakeview.get(sdk_dashboard.dashboard_id)
111+
112+
113+
def test_dashboard_deploys_dashboard_with_counter_variation(ws, make_dashboard, tmp_path):
114+
sdk_dashboard = make_dashboard()
115+
58116
with (tmp_path / "counter.sql").open("w") as f:
59117
f.write("SELECT 10 AS something_else_than_count")
60118
dashboards = Dashboards(ws)
61119
lakeview_dashboard = dashboards.create_dashboard(tmp_path)
62120

63-
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=dashboard_id)
121+
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=sdk_dashboard.dashboard_id)
64122

65123
assert ws.lakeview.get(sdk_dashboard.dashboard_id)

tests/unit/test_dashboards.py

+56-21
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from databricks.sdk import WorkspaceClient
77

8-
from databricks.labs.lsql.dashboards import Dashboards
8+
from databricks.labs.lsql.dashboards import DashboardMetadata, Dashboards
99
from databricks.labs.lsql.lakeview import (
1010
CounterEncodingMap,
1111
CounterSpec,
@@ -20,6 +20,22 @@
2020
)
2121

2222

23+
def test_dashboard_configuration_raises_key_error_if_display_name_is_missing():
24+
with pytest.raises(KeyError):
25+
DashboardMetadata.from_dict({})
26+
27+
28+
def test_dashboard_configuration_sets_display_name_from_dict():
29+
dashboard_metadata = DashboardMetadata.from_dict({"display_name": "test"})
30+
assert dashboard_metadata.display_name == "test"
31+
32+
33+
def test_dashboard_configuration_from_and_as_dict_is_a_unit_function():
34+
raw = {"display_name": "test"}
35+
dashboard_metadata = DashboardMetadata.from_dict(raw)
36+
assert dashboard_metadata.as_dict() == raw
37+
38+
2339
def test_dashboards_saves_sql_files_to_folder(tmp_path):
2440
ws = create_autospec(WorkspaceClient)
2541
queries = Path(__file__).parent / "queries"
@@ -42,6 +58,42 @@ def test_dashboards_saves_yml_files_to_folder(tmp_path):
4258
ws.assert_not_called()
4359

4460

61+
def test_dashboards_creates_dashboard_with_first_page_name_after_folder():
62+
ws = create_autospec(WorkspaceClient)
63+
queries = Path(__file__).parent / "queries"
64+
lakeview_dashboard = Dashboards(ws).create_dashboard(queries)
65+
page = lakeview_dashboard.pages[0]
66+
assert page.name == "queries"
67+
assert page.display_name == "queries"
68+
69+
70+
def test_dashboards_creates_dashboard_with_custom_first_page_name(tmp_path):
71+
with (tmp_path / "dashboard.yml").open("w") as f:
72+
f.write("display_name: Custom")
73+
74+
ws = create_autospec(WorkspaceClient)
75+
lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
76+
77+
page = lakeview_dashboard.pages[0]
78+
assert page.name == "Custom"
79+
assert page.display_name == "Custom"
80+
81+
82+
@pytest.mark.parametrize("dashboard_content", ["missing_display_name: true", "invalid:\nyml"])
83+
def test_dashboards_handles_invalid_dashboard_yml(tmp_path, dashboard_content):
84+
queries_path = tmp_path / "queries"
85+
queries_path.mkdir()
86+
with (queries_path / "dashboard.yml").open("w") as f:
87+
f.write(dashboard_content)
88+
89+
ws = create_autospec(WorkspaceClient)
90+
lakeview_dashboard = Dashboards(ws).create_dashboard(queries_path)
91+
92+
page = lakeview_dashboard.pages[0]
93+
assert page.name == "queries"
94+
assert page.display_name == "queries"
95+
96+
4597
def test_dashboards_creates_one_dataset_per_query():
4698
ws = create_autospec(WorkspaceClient)
4799
queries = Path(__file__).parent / "queries"
@@ -287,29 +339,12 @@ def test_dashboards_creates_dashboards_where_text_widget_has_expected_text(tmp_p
287339
ws.assert_not_called()
288340

289341

290-
def test_dashboards_deploy_raises_value_error_with_missing_display_name_and_dashboard_id():
342+
def test_dashboards_deploy_calls_create_without_dashboard_id():
291343
ws = create_autospec(WorkspaceClient)
292344
dashboards = Dashboards(ws)
293-
dashboard = Dashboard([], [])
294-
with pytest.raises(ValueError):
295-
dashboards.deploy_dashboard(dashboard)
296-
ws.assert_not_called()
297345

298-
299-
def test_dashboards_deploy_raises_value_error_with_both_display_name_and_dashboard_id():
300-
ws = create_autospec(WorkspaceClient)
301-
dashboards = Dashboards(ws)
302-
dashboard = Dashboard([], [])
303-
with pytest.raises(ValueError):
304-
dashboards.deploy_dashboard(dashboard, display_name="test", dashboard_id="test")
305-
ws.assert_not_called()
306-
307-
308-
def test_dashboards_deploy_calls_create_with_display_name():
309-
ws = create_autospec(WorkspaceClient)
310-
dashboards = Dashboards(ws)
311-
dashboard = Dashboard([], [])
312-
dashboards.deploy_dashboard(dashboard, display_name="test")
346+
dashboard = Dashboard([], [Page("test", [])])
347+
dashboards.deploy_dashboard(dashboard)
313348

314349
ws.lakeview.create.assert_called_once()
315350
ws.lakeview.update.assert_not_called()

0 commit comments

Comments
 (0)