Skip to content

Commit a79d40f

Browse files
authored
Set widget id in query header (#154)
1 parent 8824273 commit a79d40f

File tree

3 files changed

+152
-94
lines changed

3 files changed

+152
-94
lines changed

docs/dashboards.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,7 @@ write. Here's the example of a folder that defines a dashboard:
5555
SQL files are used to define the queries that will be used to populate the dashboard:
5656

5757
```sql
58-
-- viz type=counter, name=Workspace UC readiness, counter_label=UC readiness, value_column=readiness
59-
-- widget row=1, col=0, size_x=1, size_y=3
58+
/* --width 2 --height 6 --order 0 */
6059
WITH raw AS (
6160
SELECT object_type, object_id, IF(failures == '[]', 1, 0) AS ready
6261
FROM $inventory.objects

src/databricks/labs/lsql/dashboards.py

Lines changed: 126 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import argparse
2+
import copy
23
import dataclasses
34
import json
45
import logging
56
import shlex
67
from argparse import ArgumentParser
7-
from collections.abc import Iterable
88
from dataclasses import dataclass
99
from pathlib import Path
1010
from typing import TypeVar
@@ -29,8 +29,10 @@
2929
Position,
3030
Query,
3131
Widget,
32+
WidgetSpec,
3233
)
3334

35+
_MAXIMUM_DASHBOARD_WIDTH = 6
3436
T = TypeVar("T")
3537
logger = logging.getLogger(__name__)
3638

@@ -49,41 +51,100 @@ def as_dict(self) -> dict[str, str]:
4951
return dataclasses.asdict(self)
5052

5153

52-
@dataclass
5354
class WidgetMetadata:
54-
order: int
55-
width: int
56-
height: int
55+
def __init__(
56+
self,
57+
path: Path,
58+
order: int = 0,
59+
width: int = 0,
60+
height: int = 0,
61+
_id: str = "",
62+
):
63+
self.path = path
64+
self.order = order
65+
self.width = width
66+
self.height = height
67+
self.id = _id
68+
69+
size = self._size
70+
self.width = self.width or size[0]
71+
self.height = self.height or size[1]
72+
self.id = self.id or path.stem
73+
74+
def is_markdown(self) -> bool:
75+
return self.path.suffix == ".md"
76+
77+
@property
78+
def spec_type(self) -> type[WidgetSpec]:
79+
# TODO: When supporting more specs, infer spec from query
80+
return CounterSpec
81+
82+
@property
83+
def _size(self) -> tuple[int, int]:
84+
"""Get the width and height for a widget.
85+
86+
The tiling logic works if:
87+
- width < _MAXIMUM_DASHBOARD_WIDTH : heights for widgets on the same row should be equal
88+
- width == _MAXIMUM_DASHBOARD_WIDTH : any height
89+
"""
90+
if self.is_markdown():
91+
return _MAXIMUM_DASHBOARD_WIDTH, 2
92+
if self.spec_type == CounterSpec:
93+
return 1, 3
94+
return 0, 0
5795

5896
def as_dict(self) -> dict[str, str]:
59-
return dataclasses.asdict(self)
97+
body = {"path": self.path.as_posix()}
98+
for attribute in "order", "width", "height", "id":
99+
if attribute in body:
100+
continue
101+
value = getattr(self, attribute)
102+
if value is not None:
103+
body[attribute] = str(value)
104+
return body
60105

61106
@staticmethod
62107
def _get_arguments_parser() -> ArgumentParser:
63108
parser = ArgumentParser("WidgetMetadata", add_help=False, exit_on_error=False)
109+
parser.add_argument("--id", type=str)
64110
parser.add_argument("-o", "--order", type=int)
65111
parser.add_argument("-w", "--width", type=int)
66112
parser.add_argument("-h", "--height", type=int)
67113
return parser
68114

69115
def replace_from_arguments(self, arguments: list[str]) -> "WidgetMetadata":
116+
replica = copy.deepcopy(self)
70117
parser = self._get_arguments_parser()
71118
try:
72119
args = parser.parse_args(arguments)
73120
except (argparse.ArgumentError, SystemExit) as e:
74121
logger.warning(f"Parsing {arguments}: {e}")
75-
return dataclasses.replace(self)
76-
return dataclasses.replace(
77-
self,
78-
order=args.order or self.order,
79-
width=args.width or self.width,
80-
height=args.height or self.height,
81-
)
122+
return replica
82123

124+
replica.order = args.order or self.order
125+
replica.width = args.width or self.width
126+
replica.height = args.height or self.height
127+
replica.id = args.id or self.id
128+
return replica
129+
130+
@classmethod
131+
def from_path(cls, path: Path) -> "WidgetMetadata":
132+
fallback_metadata = cls(path=path)
133+
134+
try:
135+
parsed_query = sqlglot.parse_one(path.read_text(), dialect=sqlglot.dialects.Databricks)
136+
except sqlglot.ParseError as e:
137+
logger.warning(f"Parsing {path}: {e}")
138+
return fallback_metadata
139+
140+
if parsed_query.comments is None or len(parsed_query.comments) == 0:
141+
return fallback_metadata
142+
143+
first_comment = parsed_query.comments[0]
144+
return fallback_metadata.replace_from_arguments(shlex.split(first_comment))
83145

84-
class Dashboards:
85-
_MAXIMUM_DASHBOARD_WIDTH = 6
86146

147+
class Dashboards:
87148
def __init__(self, ws: WorkspaceClient):
88149
self._ws = ws
89150

@@ -131,9 +192,10 @@ def _format_query(query: str) -> str:
131192
def create_dashboard(self, dashboard_folder: Path) -> Dashboard:
132193
"""Create a dashboard from code, i.e. configuration and queries."""
133194
dashboard_metadata = self._parse_dashboard_metadata(dashboard_folder)
195+
widgets_metadata = self._get_widgets_metadata(dashboard_folder)
134196
datasets = self._get_datasets(dashboard_folder)
135-
widgets = self._get_widgets(dashboard_folder.iterdir(), datasets)
136-
layouts = self._get_layouts(widgets)
197+
widgets = self._get_widgets(widgets_metadata)
198+
layouts = self._get_layouts(widgets, widgets_metadata)
137199
page = Page(
138200
name=dashboard_metadata.display_name,
139201
display_name=dashboard_metadata.display_name,
@@ -152,30 +214,45 @@ def _get_datasets(dashboard_folder: Path) -> list[Dataset]:
152214
datasets.append(dataset)
153215
return datasets
154216

155-
def _get_widgets(self, files: Iterable[Path], datasets: list[Dataset]) -> list[tuple[Widget, WidgetMetadata]]:
156-
dataset_index, widgets = 0, []
157-
for order, path in enumerate(sorted(files)):
217+
@staticmethod
218+
def _get_widgets_metadata(dashboard_folder: Path) -> list[WidgetMetadata]:
219+
"""Read and parse the widget metadata from each (optional) header.
220+
221+
The order is by default the alphanumerically sorted files, however, the order may be overwritten in the file
222+
header with the `order` key. Hence, the multiple loops to get:
223+
i) the optional order from the file header;
224+
ii) set the order when not specified;
225+
iii) sort the widgets using the order field.
226+
"""
227+
widgets_metadata = []
228+
for path in sorted(dashboard_folder.iterdir()):
158229
if path.suffix not in {".sql", ".md"}:
159230
continue
160-
if path.suffix == ".sql":
161-
dataset = datasets[dataset_index]
162-
assert dataset.name == path.stem
163-
dataset_index += 1
164-
try:
165-
widget = self._get_widget(dataset)
166-
except sqlglot.ParseError as e:
167-
logger.warning(f"Parsing {dataset.query}: {e}")
168-
continue
169-
else:
170-
widget = self._get_text_widget(path)
171-
widget_metadata = self._parse_widget_metadata(path, widget, order)
172-
widgets.append((widget, widget_metadata))
231+
widget_metadata = WidgetMetadata.from_path(path)
232+
widgets_metadata.append(widget_metadata)
233+
widgets_metadata_with_order = []
234+
for order, widget_metadata in enumerate(sorted(widgets_metadata, key=lambda wm: wm.id)):
235+
replica = copy.deepcopy(widget_metadata)
236+
replica.order = widget_metadata.order or order
237+
widgets_metadata_with_order.append(replica)
238+
widgets_metadata_sorted = list(sorted(widgets_metadata_with_order, key=lambda wm: (wm.order, wm.id)))
239+
return widgets_metadata_sorted
240+
241+
def _get_widgets(self, widgets_metadata: list[WidgetMetadata]) -> list[Widget]:
242+
widgets = []
243+
for widget_metadata in widgets_metadata:
244+
try:
245+
widget = self._get_widget(widget_metadata)
246+
except sqlglot.ParseError as e:
247+
logger.warning(f"Parsing {widget_metadata.path}: {e}")
248+
continue
249+
widgets.append(widget)
173250
return widgets
174251

175-
def _get_layouts(self, widgets: list[tuple[Widget, WidgetMetadata]]) -> list[Layout]:
252+
def _get_layouts(self, widgets: list[Widget], widgets_metadata: list[WidgetMetadata]) -> list[Layout]:
176253
layouts, position = [], Position(0, 0, 0, 0) # First widget position
177-
for widget, widget_metadata in sorted(widgets, key=lambda w: (w[1].order, w[0].name)):
178-
position = self._get_position(widget_metadata, position)
254+
for widget, widget_metadata in zip(widgets, widgets_metadata):
255+
position = self._get_position(position, widget_metadata)
179256
layout = Layout(widget=widget, position=position)
180257
layouts.append(layout)
181258
return layouts
@@ -199,40 +276,25 @@ def _parse_dashboard_metadata(dashboard_folder: Path) -> DashboardMetadata:
199276
logger.warning(f"Parsing {dashboard_metadata_path}: {e}")
200277
return fallback_metadata
201278

202-
def _parse_widget_metadata(self, path: Path, widget: Widget, order: int) -> WidgetMetadata:
203-
width, height = self._get_width_and_height(widget)
204-
fallback_metadata = WidgetMetadata(
205-
order=order,
206-
width=width,
207-
height=height,
208-
)
209-
210-
try:
211-
parsed_query = sqlglot.parse_one(path.read_text(), dialect=sqlglot.dialects.Databricks)
212-
except sqlglot.ParseError as e:
213-
logger.warning(f"Parsing {path}: {e}")
214-
return fallback_metadata
215-
216-
if parsed_query.comments is None or len(parsed_query.comments) == 0:
217-
return fallback_metadata
218-
219-
first_comment = parsed_query.comments[0]
220-
return fallback_metadata.replace_from_arguments(shlex.split(first_comment))
279+
def _get_widget(self, widget_metadata: WidgetMetadata) -> Widget:
280+
if widget_metadata.is_markdown():
281+
return self._get_text_widget(widget_metadata)
282+
return self._get_counter_widget(widget_metadata)
221283

222284
@staticmethod
223-
def _get_text_widget(path: Path) -> Widget:
224-
widget = Widget(name=path.stem, textbox_spec=path.read_text())
285+
def _get_text_widget(widget_metadata: WidgetMetadata) -> Widget:
286+
widget = Widget(name=widget_metadata.id, textbox_spec=widget_metadata.path.read_text())
225287
return widget
226288

227-
def _get_widget(self, dataset: Dataset) -> Widget:
228-
fields = self._get_fields(dataset.query)
229-
query = Query(dataset_name=dataset.name, fields=fields, disaggregated=True)
289+
def _get_counter_widget(self, widget_metadata: WidgetMetadata) -> Widget:
290+
fields = self._get_fields(widget_metadata.path.read_text())
291+
query = Query(dataset_name=widget_metadata.id, fields=fields, disaggregated=True)
230292
# As far as testing went, a NamedQuery should always have "main_query" as name
231293
named_query = NamedQuery(name="main_query", query=query)
232294
# Counters are expected to have one field
233295
counter_field_encoding = CounterFieldEncoding(field_name=fields[0].name, display_name=fields[0].name)
234296
counter_spec = CounterSpec(CounterEncodingMap(value=counter_field_encoding))
235-
widget = Widget(name=dataset.name, queries=[named_query], spec=counter_spec)
297+
widget = Widget(name=widget_metadata.id, queries=[named_query], spec=counter_spec)
236298
return widget
237299

238300
@staticmethod
@@ -247,33 +309,17 @@ def _get_fields(query: str) -> list[Field]:
247309
fields.append(field)
248310
return fields
249311

250-
def _get_position(self, widget_metadata: WidgetMetadata, previous_position: Position) -> Position:
312+
@staticmethod
313+
def _get_position(previous_position: Position, widget_metadata: WidgetMetadata) -> Position:
251314
x = previous_position.x + previous_position.width
252-
if x + widget_metadata.width > self._MAXIMUM_DASHBOARD_WIDTH:
315+
if x + widget_metadata.width > _MAXIMUM_DASHBOARD_WIDTH:
253316
x = 0
254317
y = previous_position.y + previous_position.height
255318
else:
256319
y = previous_position.y
257320
position = Position(x=x, y=y, width=widget_metadata.width, height=widget_metadata.height)
258321
return position
259322

260-
def _get_width_and_height(self, widget: Widget) -> tuple[int, int]:
261-
"""Get the width and height for a widget.
262-
263-
The tiling logic works if:
264-
- width < self._MAXIMUM_DASHBOARD_WIDTH : heights for widgets on the same row should be equal
265-
- width == self._MAXIMUM_DASHBOARD_WIDTH : any height
266-
"""
267-
if widget.textbox_spec is not None:
268-
return self._MAXIMUM_DASHBOARD_WIDTH, 2
269-
270-
height = 3
271-
if isinstance(widget.spec, CounterSpec):
272-
width = 1
273-
else:
274-
raise NotImplementedError(f"No width defined for spec: {widget}")
275-
return width, height
276-
277323
def deploy_dashboard(self, lakeview_dashboard: Dashboard, *, dashboard_id: str | None = None) -> SDKDashboard:
278324
"""Deploy a lakeview dashboard."""
279325
if dashboard_id is not None:

tests/unit/test_dashboards.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import dataclasses
21
import logging
32
from pathlib import Path
43
from unittest.mock import create_autospec
@@ -41,26 +40,23 @@ def test_dashboard_configuration_from_and_as_dict_is_a_unit_function():
4140
assert dashboard_metadata.as_dict() == raw
4241

4342

44-
def test_widget_metadata_replaces_arguments():
45-
widget_metadata = WidgetMetadata(1, 1, 1)
43+
def test_widget_metadata_replaces_width_and_height():
44+
widget_metadata = WidgetMetadata(Path("test.sql"), 1, 1, 1)
4645
updated_metadata = widget_metadata.replace_from_arguments(["--width", "10", "--height", "10"])
4746
assert updated_metadata.width == 10
4847
assert updated_metadata.height == 10
4948

5049

51-
@pytest.mark.parametrize("attribute", ["order", "width", "height"])
52-
def test_widget_metadata_replaces_one_attribute(attribute: str):
53-
widget_metadata = WidgetMetadata(1, 1, 1)
50+
@pytest.mark.parametrize("attribute", ["id", "order", "width", "height"])
51+
def test_widget_metadata_replaces_attribute(attribute: str):
52+
widget_metadata = WidgetMetadata(Path("test.sql"), 1, 1, 1)
5453
updated_metadata = widget_metadata.replace_from_arguments([f"--{attribute}", "10"])
55-
56-
other_fields = [field for field in dataclasses.fields(updated_metadata) if field.name != attribute]
57-
assert getattr(updated_metadata, attribute) == 10
58-
assert all(getattr(updated_metadata, field.name) == 1 for field in other_fields)
54+
assert str(getattr(updated_metadata, attribute)) == "10"
5955

6056

6157
def test_widget_metadata_as_dict():
62-
raw = {"order": 10, "width": 10, "height": 10}
63-
widget_metadata = WidgetMetadata(10, 10, 10)
58+
raw = {"path": "test.sql", "id": "test", "order": "10", "width": "10", "height": "10"}
59+
widget_metadata = WidgetMetadata(Path("test.sql"), 10, 10, 10)
6460
assert widget_metadata.as_dict() == raw
6561

6662

@@ -343,6 +339,23 @@ def test_dashboards_creates_dashboards_with_widgets_order_overwrite(tmp_path):
343339
ws.assert_not_called()
344340

345341

342+
def test_dashboards_creates_dashboards_with_widget_ordered_using_id(tmp_path):
343+
ws = create_autospec(WorkspaceClient)
344+
345+
for query_name in "bcdef":
346+
with (tmp_path / f"{query_name}.sql").open("w") as f:
347+
f.write("SELECT 1 AS count")
348+
349+
with (tmp_path / "z.sql").open("w") as f:
350+
f.write("-- --id a\nSELECT 1 AS count") # Should be first because id is 'a'
351+
352+
lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
353+
widget_names = [layout.widget.name for layout in lakeview_dashboard.pages[0].layout]
354+
355+
assert "".join(widget_names) == "abcdef"
356+
ws.assert_not_called()
357+
358+
346359
@pytest.mark.parametrize("query, width, height", [("SELECT 1 AS count", 1, 3)])
347360
def test_dashboards_creates_dashboards_where_widget_has_expected_width_and_height(tmp_path, query, width, height):
348361
ws = create_autospec(WorkspaceClient)

0 commit comments

Comments
 (0)