11import argparse
2+ import copy
23import dataclasses
34import json
45import logging
56import shlex
67from argparse import ArgumentParser
7- from collections .abc import Iterable
88from dataclasses import dataclass
99from pathlib import Path
1010from typing import TypeVar
2929 Position ,
3030 Query ,
3131 Widget ,
32+ WidgetSpec ,
3233)
3334
35+ _MAXIMUM_DASHBOARD_WIDTH = 6
3436T = TypeVar ("T" )
3537logger = logging .getLogger (__name__ )
3638
@@ -49,41 +51,100 @@ def as_dict(self) -> dict[str, str]:
4951 return dataclasses .asdict (self )
5052
5153
52- @dataclass
5354class 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 :
0 commit comments