Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create text widget from markdown file #142

Merged
merged 17 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 45 additions & 20 deletions src/databricks/labs/lsql/dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
Position,
Query,
Widget,
WidgetSpec,
)

T = TypeVar("T")
Expand Down Expand Up @@ -88,26 +87,45 @@ def create_dashboard(self, dashboard_folder: Path) -> Dashboard:
dataset = Dataset(name=query_path.stem, display_name=query_path.stem, query=raw_query)
datasets.append(dataset)

try:
fields = self._get_fields(dataset.query)
except sqlglot.ParseError as e:
logger.warning(f"Error '{e}' when parsing: {dataset.query}")
dataset_index = 0
for path in sorted(dashboard_folder.iterdir()):
if path.suffix not in {".sql", ".md"}:
continue
query = Query(dataset_name=dataset.name, fields=fields, disaggregated=True)
# As for as testing went, a NamedQuery should always have "main_query" as name
named_query = NamedQuery(name="main_query", query=query)
# Counters are expected to have one field
counter_field_encoding = CounterFieldEncoding(field_name=fields[0].name, display_name=fields[0].name)
counter_spec = CounterSpec(CounterEncodingMap(value=counter_field_encoding))
widget = Widget(name=dataset.name, queries=[named_query], spec=counter_spec)
position = self._get_position(counter_spec, position)
if path.suffix == ".sql":
dataset = datasets[dataset_index]
assert dataset.name == path.stem
dataset_index += 1
try:
widget = self._get_widget(dataset)
except sqlglot.ParseError as e:
logger.warning(f"Error '{e}' when parsing: {dataset.query}")
continue
else:
widget = self._get_text_widget(path)
position = self._get_position(widget, position)
layout = Layout(widget=widget, position=position)
layouts.append(layout)

page = Page(name=dashboard_folder.name, display_name=dashboard_folder.name, layout=layouts)
lakeview_dashboard = Dashboard(datasets=datasets, pages=[page])
return lakeview_dashboard

@staticmethod
def _get_text_widget(path: Path) -> Widget:
widget = Widget(name=path.stem, textbox_spec=path.read_text())
return widget

def _get_widget(self, dataset: Dataset) -> Widget:
fields = self._get_fields(dataset.query)
query = Query(dataset_name=dataset.name, fields=fields, disaggregated=True)
# As far as testing went, a NamedQuery should always have "main_query" as name
named_query = NamedQuery(name="main_query", query=query)
# Counters are expected to have one field
counter_field_encoding = CounterFieldEncoding(field_name=fields[0].name, display_name=fields[0].name)
counter_spec = CounterSpec(CounterEncodingMap(value=counter_field_encoding))
widget = Widget(name=dataset.name, queries=[named_query], spec=counter_spec)
return widget

@staticmethod
def _get_fields(query: str) -> list[Field]:
parsed_query = sqlglot.parse_one(query, dialect=sqlglot.dialects.Databricks)
Expand All @@ -120,8 +138,8 @@ def _get_fields(query: str) -> list[Field]:
fields.append(field)
return fields

def _get_position(self, spec: WidgetSpec, previous_position: Position) -> Position:
width, height = self._get_width_and_height(spec)
def _get_position(self, widget: Widget, previous_position: Position) -> Position:
width, height = self._get_width_and_height(widget)
x = previous_position.x + previous_position.width
if x + width > self._MAXIMUM_DASHBOARD_WIDTH:
x = 0
Expand All @@ -131,14 +149,21 @@ def _get_position(self, spec: WidgetSpec, previous_position: Position) -> Positi
position = Position(x=x, y=y, width=width, height=height)
return position

@staticmethod
def _get_width_and_height(spec: WidgetSpec) -> tuple[int, int]:
# NOTE: The logic only works if all heights are the same
def _get_width_and_height(self, widget: Widget) -> tuple[int, int]:
"""Get the width and height for a widget.

The tiling logic works if:
- width < self._MAXIMUM_DASHBOARD_WIDTH : heights for widgets on the same row should be equal
- width == self._MAXIMUM_DASHBOARD_WIDTH : any height
"""
if widget.textbox_spec is not None:
return self._MAXIMUM_DASHBOARD_WIDTH, 2

height = 3
if isinstance(spec, CounterSpec):
if isinstance(widget.spec, CounterSpec):
width = 1
else:
raise NotImplementedError(f"No width defined for spec: {spec}")
raise NotImplementedError(f"No width defined for spec: {widget}")
return width, height

def deploy_dashboard(
Expand Down
5 changes: 5 additions & 0 deletions tests/integration/dashboards/one_counter/000_counter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Counter

Below you see an example counter widget. Counter widgets in dashboards are used to display a single,
high-level metric or KPI. They provide a quick and easy way for users to see important information at a glance, making
it convenient for monitoring and decision-making.
7 changes: 4 additions & 3 deletions tests/integration/test_dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ def test_dashboards_deploys_exported_dashboard_definition(ws, dashboard_id):
assert ws.lakeview.get(dashboard.dashboard_id)


def test_dashboard_deploys_dashboard_the_same_as_created_dashboard(ws, dashboard_id):
queries = Path(__file__).parent / "dashboards" / "one_counter"
def test_dashboard_deploys_dashboard_the_same_as_created_dashboard(tmp_path, ws, dashboard_id):
with (tmp_path / "counter.sql").open("w") as f:
f.write("SELECT 10 AS count")
dashboards = Dashboards(ws)
dashboard = dashboards.create_dashboard(queries)
dashboard = dashboards.create_dashboard(tmp_path)

sdk_dashboard = dashboards.deploy_dashboard(dashboard, dashboard_id=dashboard_id)
new_dashboard = dashboards.get_dashboard(sdk_dashboard.path)
Expand Down
62 changes: 60 additions & 2 deletions tests/unit/test_dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,32 @@ def test_dashboards_creates_one_counter_widget_per_query():
def test_dashboards_skips_invalid_query(tmp_path, caplog):
ws = create_autospec(WorkspaceClient)

# Test for the invalid query not to be the first or last query
for i in range(0, 3, 2):
with (tmp_path / f"{i}_counter.sql").open("w") as f:
f.write(f"SELECT {i} AS count")

invalid_query = "SELECT COUNT(* AS missing_closing_parenthesis"
with (tmp_path / "invalid.sql").open("w") as f:
with (tmp_path / "1_invalid.sql").open("w") as f:
f.write(invalid_query)

with caplog.at_level(logging.WARNING, logger="databricks.labs.lsql.dashboards"):
lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)

assert len(lakeview_dashboard.pages[0].layout) == 0
assert len(lakeview_dashboard.pages[0].layout) == 2
assert invalid_query in caplog.text


def test_dashboards_does_not_create_widget_for_yml_file(tmp_path, caplog):
ws = create_autospec(WorkspaceClient)

with (tmp_path / "dashboard.yml").open("w") as f:
f.write("display_name: Git based dashboard")

lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
assert len(lakeview_dashboard.pages[0].layout) == 0


@pytest.mark.parametrize(
"query, names",
[
Expand Down Expand Up @@ -199,6 +214,21 @@ def test_dashboards_creates_dashboard_with_many_widgets_not_on_the_first_row(tmp
ws.assert_not_called()


def test_dashboards_creates_dashboard_with_widget_below_text_widget(tmp_path):
ws = create_autospec(WorkspaceClient)
with (tmp_path / "000_counter.md").open("w") as f:
f.write("# Description")
with (tmp_path / "010_counter.sql").open("w") as f:
f.write("SELECT 100 AS count")

lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
layout = lakeview_dashboard.pages[0].layout

assert len(layout) == 2
assert layout[0].position.y < layout[1].position.y
ws.assert_not_called()


@pytest.mark.parametrize("query_names", [["a", "b", "c"], ["01", "02", "10"]])
def test_dashboards_creates_dashboards_with_widgets_sorted_alphanumerically(tmp_path, query_names):
ws = create_autospec(WorkspaceClient)
Expand Down Expand Up @@ -229,6 +259,34 @@ def test_dashboards_creates_dashboards_where_widget_has_expected_width_and_heigh
ws.assert_not_called()


def test_dashboards_creates_dashboards_where_text_widget_has_expected_width_and_height(tmp_path):
ws = create_autospec(WorkspaceClient)

with (tmp_path / "description.md").open("w") as f:
f.write("# Description")

lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
position = lakeview_dashboard.pages[0].layout[0].position

assert position.width == 6
assert position.height == 2
ws.assert_not_called()


def test_dashboards_creates_dashboards_where_text_widget_has_expected_text(tmp_path):
ws = create_autospec(WorkspaceClient)

content = "# Description"
with (tmp_path / "description.md").open("w") as f:
f.write(content)

lakeview_dashboard = Dashboards(ws).create_dashboard(tmp_path)
widget = lakeview_dashboard.pages[0].layout[0].widget

assert widget.textbox_spec == content
ws.assert_not_called()


def test_dashboards_deploy_raises_value_error_with_missing_display_name_and_dashboard_id():
ws = create_autospec(WorkspaceClient)
dashboards = Dashboards(ws)
Expand Down
Loading