Skip to content

Commit 9ca75f8

Browse files
authored
Programmatically create a dashboard (#121)
This PR adds ability to programmatically create a dashboard. Vizualization and widget options are reverse-engineered from POST statements sent through UI. <img width="1844" alt="image" src="https://github.com/databricks/ucx/assets/259697/7c106e08-c49b-4abb-a36c-dc7dcd7fa713">
1 parent 21539de commit 9ca75f8

File tree

4 files changed

+347
-9
lines changed

4 files changed

+347
-9
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ ban-relative-imports = "all"
176176
[tool.ruff.per-file-ignores]
177177
# Tests can use magic values, assertions, and relative imports
178178
"tests/**/*" = ["PLR2004", "S101", "TID252"]
179+
"src/databricks/labs/ucx/providers/mixins/redash.py" = ["A002", "A003", "N815"]
179180

180181
[tool.coverage.run]
181182
branch = true

src/databricks/labs/ucx/providers/mixins/compute.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,12 @@ def __init__(self, ws: WorkspaceClient, cluster_id: str | None = None, language:
7070
def run(self, code):
7171
code = self._trim_leading_whitespace(code)
7272

73-
# perform AST transformations for very repetitive tasks, like JSON serialization
74-
code_tree = ast.parse(code)
75-
json_serialize_transform = _ReturnToPrintJsonTransformer()
76-
new_tree = json_serialize_transform.apply(code_tree)
77-
code = ast.unparse(new_tree)
73+
if self._language == Language.PYTHON:
74+
# perform AST transformations for very repetitive tasks, like JSON serialization
75+
code_tree = ast.parse(code)
76+
json_serialize_transform = _ReturnToPrintJsonTransformer()
77+
new_tree = json_serialize_transform.apply(code_tree)
78+
code = ast.unparse(new_tree)
7879

7980
ctx = self._running_command_context()
8081
result = self._commands.execute(
@@ -84,7 +85,11 @@ def run(self, code):
8485
results = result.results
8586
if result.status == compute.CommandStatus.FINISHED:
8687
self._raise_if_failed(results)
87-
if results.result_type == compute.ResultType.TEXT and json_serialize_transform.has_return:
88+
if (
89+
self._language == Language.PYTHON
90+
and results.result_type == compute.ResultType.TEXT
91+
and json_serialize_transform.has_return
92+
):
8893
# parse json from converted return statement
8994
return json.loads(results.data)
9095
return results.data
@@ -95,9 +100,9 @@ def run(self, code):
95100
def install_notebook_library(self, library):
96101
return self.run(
97102
f"""
98-
get_ipython().run_line_magic('pip', 'install {library}')
99-
dbutils.library.restartPython()
100-
"""
103+
get_ipython().run_line_magic('pip', 'install {library}')
104+
dbutils.library.restartPython()
105+
"""
101106
)
102107

103108
def _running_command_context(self) -> compute.ContextStatusResponse:
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import dataclasses
2+
from dataclasses import dataclass
3+
from typing import Any, Optional
4+
5+
from databricks.sdk.service._internal import _from_dict
6+
from databricks.sdk.service.sql import Visualization, Widget
7+
8+
9+
@dataclass
10+
class WidgetOptions:
11+
created_at: str | None = None
12+
description: str | None = None
13+
is_hidden: bool | None = None
14+
parameter_mappings: Any | None = None
15+
position: Optional["WidgetPosition"] = None
16+
title: str | None = None
17+
updated_at: str | None = None
18+
19+
def as_dict(self) -> dict:
20+
body = {}
21+
if self.created_at is not None:
22+
body["created_at"] = self.created_at
23+
if self.description is not None:
24+
body["description"] = self.description
25+
if self.is_hidden is not None:
26+
body["isHidden"] = self.is_hidden
27+
if self.parameter_mappings:
28+
body["parameterMappings"] = self.parameter_mappings
29+
if self.position:
30+
body["position"] = self.position.as_dict()
31+
if self.title is not None:
32+
body["title"] = self.title
33+
if self.updated_at is not None:
34+
body["updated_at"] = self.updated_at
35+
return body
36+
37+
@classmethod
38+
def from_dict(cls, d: dict[str, any]) -> "WidgetOptions":
39+
return cls(
40+
created_at=d.get("created_at", None),
41+
description=d.get("description", None),
42+
is_hidden=d.get("isHidden", None),
43+
parameter_mappings=d.get("parameterMappings", None),
44+
position=_from_dict(d, "position", WidgetPosition),
45+
title=d.get("title", None),
46+
updated_at=d.get("updated_at", None),
47+
)
48+
49+
50+
@dataclass
51+
class WidgetPosition:
52+
"""Coordinates of this widget on a dashboard. This portion of the API changes frequently and is
53+
unsupported."""
54+
55+
auto_height: bool | None = None
56+
col: int | None = None
57+
row: int | None = None
58+
size_x: int | None = None
59+
size_y: int | None = None
60+
61+
def as_dict(self) -> dict:
62+
body = {}
63+
if self.auto_height is not None:
64+
body["autoHeight"] = self.auto_height
65+
if self.col is not None:
66+
body["col"] = self.col
67+
if self.row is not None:
68+
body["row"] = self.row
69+
if self.size_x is not None:
70+
body["sizeX"] = self.size_x
71+
if self.size_y is not None:
72+
body["sizeY"] = self.size_y
73+
return body
74+
75+
@classmethod
76+
def from_dict(cls, d: dict[str, any]) -> "WidgetPosition":
77+
return cls(
78+
auto_height=d.get("autoHeight", None),
79+
col=d.get("col", None),
80+
row=d.get("row", None),
81+
size_x=d.get("sizeX", None),
82+
size_y=d.get("sizeY", None),
83+
)
84+
85+
86+
class DashboardWidgetsAPI:
87+
"""This is an evolving API that facilitates the addition and removal of widgets from existing dashboards
88+
within the Databricks Workspace. Data structures may change over time."""
89+
90+
def __init__(self, api_client):
91+
self._api = api_client
92+
93+
def create(
94+
self,
95+
dashboard_id: str,
96+
options: WidgetOptions,
97+
*,
98+
text: str | None = None,
99+
visualization_id: str | None = None,
100+
width: int | None = None,
101+
) -> Widget:
102+
"""Add widget to a dashboard.
103+
104+
:param dashboard_id: str
105+
Dashboard ID returned by :method:dashboards/create.
106+
:param options: :class:`WidgetOptions` (optional)
107+
:param text: str (optional)
108+
If this is a textbox widget, the application displays this text. This field is ignored if the widget
109+
contains a visualization in the `visualization` field.
110+
:param visualization_id: str (optional)
111+
Query Vizualization ID returned by :method:queryvisualizations/create.
112+
:param width: int (optional)
113+
Width of a widget
114+
115+
:returns: :class:`Widget`
116+
"""
117+
body = {}
118+
if dashboard_id is not None:
119+
body["dashboard_id"] = dashboard_id
120+
if options is not None:
121+
body["options"] = options.as_dict()
122+
if text is not None:
123+
body["text"] = text
124+
if visualization_id is not None:
125+
body["visualization_id"] = visualization_id
126+
if width is not None:
127+
body["width"] = width
128+
res = self._api.do("POST", "/api/2.0/preview/sql/widgets", body=body)
129+
return Widget.from_dict(res)
130+
131+
def delete(self, id: str):
132+
self._api.do("DELETE", f"/api/2.0/preview/sql/widgets/{id}")
133+
134+
def update(
135+
self,
136+
dashboard_id: str,
137+
id: str,
138+
*,
139+
options: WidgetOptions | None = None,
140+
text: str | None = None,
141+
visualization_id: str | None = None,
142+
width: int | None = None,
143+
) -> Widget:
144+
"""Update existing widget.
145+
146+
:param dashboard_id: str
147+
Dashboard ID returned by :method:dashboards/create.
148+
:param id: str
149+
:param options: :class:`WidgetOptions` (optional)
150+
:param text: str (optional)
151+
If this is a textbox widget, the application displays this text. This field is ignored if the widget
152+
contains a visualization in the `visualization` field.
153+
:param visualization_id: str (optional)
154+
Query Vizualization ID returned by :method:queryvisualizations/create.
155+
:param width: int (optional)
156+
Width of a widget
157+
158+
:returns: :class:`Widget`
159+
"""
160+
body = {}
161+
if dashboard_id is not None:
162+
body["dashboard_id"] = dashboard_id
163+
if options is not None:
164+
body["options"] = options.as_dict()
165+
if text is not None:
166+
body["text"] = text
167+
if visualization_id is not None:
168+
body["visualization_id"] = visualization_id
169+
if width is not None:
170+
body["width"] = width
171+
res = self._api.do("POST", f"/api/2.0/preview/sql/widgets/{id}", body=body)
172+
return Widget.from_dict(res)
173+
174+
175+
class QueryVisualizationsAPI:
176+
"""This is an evolving API that facilitates the addition and removal of vizualisations from existing queries
177+
within the Databricks Workspace. Data structures may change over time."""
178+
179+
def __init__(self, api_client):
180+
self._api = api_client
181+
182+
def create(
183+
self,
184+
query_id: str,
185+
type: str,
186+
options: dict,
187+
*,
188+
created_at: str | None = None,
189+
description: str | None = None,
190+
name: str | None = None,
191+
updated_at: str | None = None,
192+
) -> Visualization:
193+
body = {}
194+
if query_id is not None:
195+
body["query_id"] = query_id
196+
if type is not None:
197+
body["type"] = type
198+
if options is not None:
199+
body["options"] = options
200+
if name is not None:
201+
body["name"] = name
202+
if created_at is not None:
203+
body["created_at"] = created_at
204+
if description is not None:
205+
body["description"] = description
206+
if updated_at is not None:
207+
body["updated_at"] = updated_at
208+
res = self._api.do("POST", "/api/2.0/preview/sql/visualizations", body=body)
209+
return Visualization.from_dict(res)
210+
211+
def delete(self, id: str):
212+
"""Remove visualization.
213+
214+
:param id: str
215+
"""
216+
217+
headers = {
218+
"Accept": "application/json",
219+
}
220+
self._api.do("DELETE", f"/api/2.0/preview/sql/visualizations/{id}", headers=headers)
221+
222+
223+
@dataclass
224+
class VizColumn:
225+
name: str
226+
title: str
227+
type: str = "string"
228+
imageUrlTemplate: str = "{{ @ }}"
229+
imageTitleTemplate: str = "{{ @ }}"
230+
linkUrlTemplate: str = "{{ @ }}"
231+
linkTextTemplate: str = "{{ @ }}"
232+
linkTitleTemplate: str = "{{ @ }}"
233+
linkOpenInNewTab: bool = True
234+
displayAs: str = "string"
235+
visible: bool = True
236+
order: int = 100000
237+
allowSearch: bool = False
238+
alignContent: str = "left"
239+
allowHTML: bool = False
240+
highlightLinks: bool = False
241+
useMonospaceFont: bool = False
242+
preserveWhitespace: bool = False
243+
244+
def as_dict(self):
245+
return dataclasses.asdict(self)
246+
247+
248+
class QueryVisualizationsExt(QueryVisualizationsAPI):
249+
def create_table(
250+
self,
251+
query_id: str,
252+
name: str,
253+
columns: list[VizColumn],
254+
*,
255+
items_per_page: int = 25,
256+
condensed=True,
257+
with_row_number=False,
258+
description: str | None = None,
259+
):
260+
return self.create(
261+
query_id,
262+
"TABLE",
263+
{
264+
"itemsPerPage": items_per_page,
265+
"condensed": condensed,
266+
"withRowNumber": with_row_number,
267+
"version": 2,
268+
"columns": [x.as_dict() for x in columns],
269+
},
270+
name=name,
271+
description=description,
272+
)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import os
2+
3+
import pytest
4+
from databricks.sdk import WorkspaceClient
5+
from databricks.sdk.service.sql import AccessControl, ObjectTypePlural, PermissionLevel
6+
7+
from databricks.labs.ucx.providers.mixins.redash import (
8+
DashboardWidgetsAPI,
9+
QueryVisualizationsExt,
10+
VizColumn,
11+
WidgetOptions,
12+
WidgetPosition,
13+
)
14+
15+
# logging.getLogger("databricks").setLevel("DEBUG")
16+
17+
18+
def test_creating_widgets(ws: WorkspaceClient):
19+
pytest.skip()
20+
dashboard_widgets_api = DashboardWidgetsAPI(ws.api_client)
21+
query_visualizations_api = QueryVisualizationsExt(ws.api_client)
22+
23+
x = ws.dashboards.create(name="test dashboard")
24+
ws.dbsql_permissions.set(
25+
ObjectTypePlural.DASHBOARDS,
26+
x.id,
27+
access_control_list=[AccessControl(group_name="users", permission_level=PermissionLevel.CAN_MANAGE)],
28+
)
29+
30+
dashboard_widgets_api.create(
31+
x.id,
32+
WidgetOptions(
33+
title="first widget",
34+
description="description of the widget",
35+
position=WidgetPosition(col=0, row=0, size_x=3, size_y=3),
36+
),
37+
text="this is _some_ **markdown**",
38+
width=1,
39+
)
40+
41+
dashboard_widgets_api.create(
42+
x.id,
43+
WidgetOptions(title="second", position=WidgetPosition(col=0, row=3, size_x=3, size_y=3)),
44+
text="another text",
45+
width=1,
46+
)
47+
48+
data_sources = {x.warehouse_id: x.id for x in ws.data_sources.list()}
49+
warehouse_id = os.environ["TEST_DEFAULT_WAREHOUSE_ID"]
50+
51+
query = ws.queries.create(
52+
data_source_id=data_sources[warehouse_id],
53+
description="abc",
54+
name="this is a test query",
55+
query="SHOW DATABASES",
56+
run_as_role="viewer",
57+
)
58+
59+
y = query_visualizations_api.create_table(query.id, "ABC Viz", [VizColumn(name="databaseName", title="DB")])
60+
print(y)

0 commit comments

Comments
 (0)