Skip to content

Commit 40e46e1

Browse files
authored
Use widget width and height defined in query header (#147)
Resolves #114 <img width="1701" alt="Screenshot 2024-06-12 at 14 10 39" src="https://github.com/databrickslabs/lsql/assets/5946784/84f66335-bf42-4b3a-a52c-e0f3534d8fa2">
1 parent 2a94673 commit 40e46e1

File tree

4 files changed

+197
-8
lines changed

4 files changed

+197
-8
lines changed

docs/dashboards.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@ when the metadata cannot be inferred from the query itself.
7979

8080
### Headers of SQL files
8181

82-
The first line is used to define widget and vizualization metadata used to render the relevant portion of the dashboard.
83-
Metadata could be defined in `--` and `/* ... */` comments, which are detected by our SQL parser.
82+
The header is used to define widget and vizualization metadata used to render the relevant portion of the dashboard.
83+
Metadata could be defined in a `--` or `/* ... */` comment, which are detected by our SQL parser. The parser only reads
84+
the **comment starting on the top**, which can be a single line using `--` or span multiple lines
85+
using `/* ... */`.
8486

8587
| Format | Readability | Verbosity |
8688
|--------------|-------------|-----------|
@@ -89,6 +91,15 @@ Metadata could be defined in `--` and `/* ... */` comments, which are detected b
8991
| `argparse` | ? | lowest |
9092
| Query string | ? | ? |
9193

94+
#### Widget arguments
95+
96+
The following widget arguments are supported:
97+
98+
| Flag | Description | Type | Optional |
99+
|----------------|---------------------------------------------|------|----------|
100+
| -w or --width | The number of columns that the widget spans | int | Yes |
101+
| -h or --height | The number of rows that the widget spans | int | Yes |
102+
92103
[[back to top](#dashboards-as-code)]
93104

94105
### Implicit detection

src/databricks/labs/lsql/dashboards.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import argparse
12
import dataclasses
23
import json
34
import logging
5+
import shlex
6+
from argparse import ArgumentParser
47
from dataclasses import dataclass
58
from pathlib import Path
69
from typing import TypeVar
@@ -45,6 +48,35 @@ def as_dict(self) -> dict[str, str]:
4548
return dataclasses.asdict(self)
4649

4750

51+
@dataclass
52+
class WidgetMetadata:
53+
width: int
54+
height: int
55+
56+
def as_dict(self) -> dict[str, str]:
57+
return dataclasses.asdict(self)
58+
59+
@staticmethod
60+
def _get_arguments_parser() -> ArgumentParser:
61+
parser = ArgumentParser("WidgetMetadata", add_help=False, exit_on_error=False)
62+
parser.add_argument("-w", "--width", type=int)
63+
parser.add_argument("-h", "--height", type=int)
64+
return parser
65+
66+
def replace_from_arguments(self, arguments: list[str]) -> "WidgetMetadata":
67+
parser = self._get_arguments_parser()
68+
try:
69+
args = parser.parse_args(arguments)
70+
except (argparse.ArgumentError, SystemExit) as e:
71+
logger.warning(f"Parsing {arguments}: {e}")
72+
return dataclasses.replace(self)
73+
return dataclasses.replace(
74+
self,
75+
width=args.width or self.width,
76+
height=args.height or self.height,
77+
)
78+
79+
4880
class Dashboards:
4981
_MAXIMUM_DASHBOARD_WIDTH = 6
5082

@@ -119,7 +151,8 @@ def create_dashboard(self, dashboard_folder: Path) -> Dashboard:
119151
continue
120152
else:
121153
widget = self._get_text_widget(path)
122-
position = self._get_position(widget, position)
154+
widget_metadata = self._parse_widget_metadata(path, widget)
155+
position = self._get_position(widget_metadata, position)
123156
layout = Layout(widget=widget, position=position)
124157
layouts.append(layout)
125158

@@ -150,6 +183,22 @@ def _parse_dashboard_metadata(dashboard_folder: Path) -> DashboardMetadata:
150183
logger.warning(f"Parsing {dashboard_metadata_path}: {e}")
151184
return fallback_metadata
152185

186+
def _parse_widget_metadata(self, path: Path, widget: Widget) -> WidgetMetadata:
187+
width, height = self._get_width_and_height(widget)
188+
fallback_metadata = WidgetMetadata(width, height)
189+
190+
try:
191+
parsed_query = sqlglot.parse_one(path.read_text(), dialect=sqlglot.dialects.Databricks)
192+
except sqlglot.ParseError as e:
193+
logger.warning(f"Parsing {path}: {e}")
194+
return fallback_metadata
195+
196+
if parsed_query.comments is None or len(parsed_query.comments) == 0:
197+
return fallback_metadata
198+
199+
first_comment = parsed_query.comments[0]
200+
return fallback_metadata.replace_from_arguments(shlex.split(first_comment))
201+
153202
@staticmethod
154203
def _get_text_widget(path: Path) -> Widget:
155204
widget = Widget(name=path.stem, textbox_spec=path.read_text())
@@ -178,15 +227,14 @@ def _get_fields(query: str) -> list[Field]:
178227
fields.append(field)
179228
return fields
180229

181-
def _get_position(self, widget: Widget, previous_position: Position) -> Position:
182-
width, height = self._get_width_and_height(widget)
230+
def _get_position(self, widget_metadata: WidgetMetadata, previous_position: Position) -> Position:
183231
x = previous_position.x + previous_position.width
184-
if x + width > self._MAXIMUM_DASHBOARD_WIDTH:
232+
if x + widget_metadata.width > self._MAXIMUM_DASHBOARD_WIDTH:
185233
x = 0
186234
y = previous_position.y + previous_position.height
187235
else:
188236
y = previous_position.y
189-
position = Position(x=x, y=y, width=width, height=height)
237+
position = Position(x=x, y=y, width=widget_metadata.width, height=widget_metadata.height)
190238
return position
191239

192240
def _get_width_and_height(self, widget: Widget) -> tuple[int, int]:

tests/integration/test_dashboards.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,17 @@ def test_dashboard_deploys_dashboard_with_counter_variation(ws, make_dashboard,
121121
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=sdk_dashboard.dashboard_id)
122122

123123
assert ws.lakeview.get(sdk_dashboard.dashboard_id)
124+
125+
126+
def test_dashboard_deploys_dashboard_with_big_widget(ws, make_dashboard, tmp_path):
127+
sdk_dashboard = make_dashboard()
128+
129+
query = """-- --width 6 --height 3\nSELECT 82917019218921 AS big_number_needs_big_widget"""
130+
with (tmp_path / "counter.sql").open("w") as f:
131+
f.write(query)
132+
dashboards = Dashboards(ws)
133+
lakeview_dashboard = dashboards.create_dashboard(tmp_path)
134+
135+
sdk_dashboard = dashboards.deploy_dashboard(lakeview_dashboard, dashboard_id=sdk_dashboard.dashboard_id)
136+
137+
assert ws.lakeview.get(sdk_dashboard.dashboard_id)

tests/unit/test_dashboards.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import dataclasses
12
import logging
23
from pathlib import Path
34
from unittest.mock import create_autospec
45

56
import pytest
67
from databricks.sdk import WorkspaceClient
78

8-
from databricks.labs.lsql.dashboards import DashboardMetadata, Dashboards
9+
from databricks.labs.lsql.dashboards import (
10+
DashboardMetadata,
11+
Dashboards,
12+
WidgetMetadata,
13+
)
914
from databricks.labs.lsql.lakeview import (
1015
CounterEncodingMap,
1116
CounterSpec,
@@ -36,6 +41,29 @@ def test_dashboard_configuration_from_and_as_dict_is_a_unit_function():
3641
assert dashboard_metadata.as_dict() == raw
3742

3843

44+
def test_widget_metadata_replaces_arguments():
45+
widget_metadata = WidgetMetadata(1, 1)
46+
updated_metadata = widget_metadata.replace_from_arguments(["--width", "10", "--height", "10"])
47+
assert updated_metadata.width == 10
48+
assert updated_metadata.height == 10
49+
50+
51+
@pytest.mark.parametrize("attribute", ["width", "height"])
52+
def test_widget_metadata_replaces_one_attribute(attribute: str):
53+
widget_metadata = WidgetMetadata(1, 1)
54+
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)
59+
60+
61+
def test_widget_metadata_as_dict():
62+
raw = {"width": 10, "height": 10}
63+
widget_metadata = WidgetMetadata(10, 10)
64+
assert widget_metadata.as_dict() == raw
65+
66+
3967
def test_dashboards_saves_sql_files_to_folder(tmp_path):
4068
ws = create_autospec(WorkspaceClient)
4169
queries = Path(__file__).parent / "queries"
@@ -339,6 +367,94 @@ def test_dashboards_creates_dashboards_where_text_widget_has_expected_text(tmp_p
339367
ws.assert_not_called()
340368

341369

370+
@pytest.mark.parametrize(
371+
"header",
372+
[
373+
"-- --width 6 --height 3",
374+
"/* --width 6 --height 3 */",
375+
"/*\n--width 6\n--height 3 */",
376+
],
377+
)
378+
def test_dashboard_creates_dashboard_with_custom_sized_widget(tmp_path, header):
379+
ws = create_autospec(WorkspaceClient)
380+
381+
query = f"{header}\nSELECT 82917019218921 AS big_number_needs_big_widget"
382+
with (tmp_path / "counter.sql").open("w") as f:
383+
f.write(query)
384+
385+
lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
386+
position = lakeview_dashboard.pages[0].layout[0].position
387+
388+
assert position.width == 6
389+
assert position.height == 3
390+
ws.assert_not_called()
391+
392+
393+
def test_dashboard_handles_incorrect_query_header(tmp_path, caplog):
394+
ws = create_autospec(WorkspaceClient)
395+
396+
# Typo is on purpose
397+
query = "-- --widh 6 --height 3 \nSELECT 82917019218921 AS big_number_needs_big_widget"
398+
with (tmp_path / "counter.sql").open("w") as f:
399+
f.write(query)
400+
401+
with caplog.at_level(logging.WARNING, logger="databricks.labs.lsql.dashboards"):
402+
lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
403+
404+
position = lakeview_dashboard.pages[0].layout[0].position
405+
assert position.width == 1
406+
assert position.height == 3
407+
assert "--widh" in caplog.text
408+
ws.assert_not_called()
409+
410+
411+
@pytest.mark.parametrize(
412+
"query",
413+
[
414+
"-- --height 5\nSELECT 1 AS count -- --width 6",
415+
"-- --height 5\nSELECT 1 AS count\n-- --width 6",
416+
"-- --height 5\nSELECT 1 AS count\n/* --width 6 */",
417+
"-- --height 5\n-- --width 6\nSELECT 1 AS count",
418+
"-- --height 5\n/* --width 6 */\nSELECT 1 AS count",
419+
"/* --height 5*/\n/* --width 6 */\nSELECT 1 AS count",
420+
"/* --height 5*/\n-- --width 6 */\nSELECT 1 AS count",
421+
],
422+
)
423+
def test_dashboard_ignores_comment_on_other_lines(tmp_path, query):
424+
ws = create_autospec(WorkspaceClient)
425+
426+
with (tmp_path / "counter.sql").open("w") as f:
427+
f.write(query)
428+
429+
lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
430+
position = lakeview_dashboard.pages[0].layout[0].position
431+
432+
assert position.width == 1
433+
assert position.height == 5
434+
ws.assert_not_called()
435+
436+
437+
@pytest.mark.parametrize(
438+
"query",
439+
[
440+
"SELECT 1\n-- --width 6 --height 6",
441+
"SELECT 1\n/*\n--width 6\n--height 6*/",
442+
],
443+
)
444+
def test_dashboard_ignores_non_header_comment(tmp_path, query):
445+
ws = create_autospec(WorkspaceClient)
446+
447+
with (tmp_path / "counter.sql").open("w") as f:
448+
f.write(query)
449+
450+
lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
451+
position = lakeview_dashboard.pages[0].layout[0].position
452+
453+
assert position.width == 1
454+
assert position.height == 3
455+
ws.assert_not_called()
456+
457+
342458
def test_dashboards_deploy_calls_create_without_dashboard_id():
343459
ws = create_autospec(WorkspaceClient)
344460
dashboards = Dashboards(ws)

0 commit comments

Comments
 (0)