Coverage for /home/ubuntu/hidebound/python/hidebound/server/components.py: 100%

138 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-07-05 23:50 +0000

1from typing import Any, Dict, List, Optional, Union # noqa F401 

2from collections import OrderedDict # noqa F401 

3import flask # noqa F401 

4 

5import re 

6 

7from dash import dash_table, dcc, html 

8from pandas import DataFrame 

9import dash 

10import dash_cytoscape as cyto 

11import rolling_pin.blob_etl as blob_etl 

12# ------------------------------------------------------------------------------ 

13 

14 

15COLOR_SCHEME = dict( 

16 dark1='#040404', 

17 dark2='#141414', 

18 bg='#181818', 

19 grey1='#242424', 

20 grey2='#444444', 

21 light1='#A4A4A4', 

22 light2='#F4F4F4', 

23 dialog1='#444459', 

24 dialog2='#5D5D7A', 

25 red1='#F77E70', 

26 red2='#DE958E', 

27 orange1='#EB9E58', 

28 orange2='#EBB483', 

29 yellow1='#E8EA7E', 

30 yellow2='#E9EABE', 

31 green1='#8BD155', 

32 green2='#A0D17B', 

33 cyan1='#7EC4CF', 

34 cyan2='#B6ECF3', 

35 blue1='#5F95DE', 

36 blue2='#93B6E6', 

37 purple1='#C98FDE', 

38 purple2='#AC92DE', 

39) # type: Dict[str, str] 

40COLORS = [ 

41 'cyan1', 

42 'red1', 

43 'green1', 

44 'blue1', 

45 'purple1', 

46 'orange1', 

47 'yellow1', 

48 'light1', 

49 'cyan2', 

50 'red2', 

51 'blue2', 

52 'green2', 

53] # type: List[str] 

54FONT_FAMILY = 'sans-serif, "sans serif"' # type: str 

55 

56 

57# APP--------------------------------------------------------------------------- 

58def get_dash_app(server, seconds=5.0, storage_type='session'): 

59 # type: (flask.Flask, float, str) -> dash.Dash 

60 ''' 

61 Generate Dash Flask app instance. 

62 

63 Args: 

64 server (Flask): Flask instance. 

65 seconds (float, optional): Time between progress updates. Default: 5. 

66 storage_type (str): Storage type (used for testing). Default: session. 

67 

68 Returns: 

69 Dash: Dash app instance. 

70 ''' 

71 store = dcc.Store(id='store', storage_type=storage_type) 

72 

73 tab_style = { 

74 'padding': '4px', 

75 'background': COLOR_SCHEME['bg'], 

76 'color': COLOR_SCHEME['light1'], 

77 'border': '0px', 

78 } 

79 tab_selected_style = { 

80 'padding': '4px', 

81 'background': COLOR_SCHEME['grey1'], 

82 'color': COLOR_SCHEME['cyan2'], 

83 'border': '0px', 

84 } 

85 tabs = dcc.Tabs( 

86 id='tabs', 

87 className='tabs', 

88 value='data', 

89 children=[ 

90 dcc.Tab( 

91 id='logo', 

92 className='tab', 

93 label='HIDEBOUND', 

94 value='', 

95 disabled_style=tab_style, 

96 disabled=True, 

97 ), 

98 dcc.Tab( 

99 className='tab', 

100 label='data', 

101 value='data', 

102 style=tab_style, 

103 selected_style=tab_selected_style, 

104 ), 

105 dcc.Tab( 

106 className='tab', 

107 label='graph', 

108 value='graph', 

109 style=tab_style, 

110 selected_style=tab_selected_style, 

111 ), 

112 dcc.Tab( 

113 className='tab', 

114 label='config', 

115 value='config', 

116 style=tab_style, 

117 selected_style=tab_selected_style, 

118 ), 

119 dcc.Tab( 

120 className='tab', 

121 label='api', 

122 value='api', 

123 style=tab_style, 

124 selected_style=tab_selected_style, 

125 ), 

126 dcc.Tab( 

127 className='tab', 

128 label='docs', 

129 value='docs', 

130 style=tab_style, 

131 selected_style=tab_selected_style, 

132 ) 

133 ], 

134 ) 

135 content = html.Div( 

136 id="content-container", 

137 className='content-container', 

138 children=[ 

139 html.Div( 

140 id="progressbar-container", className='progressbar-container', 

141 ), 

142 html.Div(id="content", className='content') 

143 ], 

144 ) 

145 clock = dcc.Interval(id='clock', interval=int(seconds * 1000)) 

146 

147 app = dash.Dash( 

148 server=server, 

149 name='hidebound', 

150 title='Hidebound', 

151 update_title=None, 

152 external_stylesheets=['/static/style.css'], 

153 suppress_callback_exceptions=True, 

154 ) 

155 app.layout = html.Div(id='layout', children=[store, clock, tabs, content]) 

156 

157 return app 

158 

159 

160# TABS-------------------------------------------------------------------------- 

161def get_data_tab(query=None): 

162 # type: (Optional[str]) -> List 

163 ''' 

164 Get tab element for Hidebound data. 

165 

166 Args: 

167 query (str, optional): Query string. Default: None. 

168 

169 Return: 

170 list: List of elements for data tab. 

171 ''' 

172 # dummies muist go first for element props behavior to work 

173 return [*get_dummy_elements(), get_searchbar(query)] 

174 

175 

176def get_config_tab(config): 

177 # type: (Dict) -> List 

178 ''' 

179 Get tab element for Hidebound config. 

180 

181 Args: 

182 config (dict): Configuration to be displayed. 

183 

184 Return: 

185 list: List of elements for config tab. 

186 ''' 

187 # dummies muist go first for element props behavior to work 

188 return [*get_dummy_elements(), get_configbar(config)] 

189 

190 

191# MENUBARS---------------------------------------------------------------------- 

192def get_searchbar(query=None): 

193 # type: (Optional[str]) -> html.Div 

194 ''' 

195 Get a row of elements used for querying Hidebound data. 

196 

197 Args: 

198 query (str, optional): Query string. Default: None. 

199 

200 Returns: 

201 Div: Div with query field, buttons and dropdown. 

202 ''' 

203 if query is None: 

204 query = 'SELECT * FROM data' 

205 

206 spacer = html.Div(className='col spacer') 

207 query = dcc.Input( 

208 id='query', 

209 className='col query', 

210 value=query, 

211 placeholder='SQL query that uses "FROM data"', 

212 type='text', 

213 autoFocus=True, 

214 debounce=True, 

215 n_submit=0, 

216 ) 

217 dropdown = get_dropdown(['asset', 'file']) 

218 

219 search = get_button('search') 

220 workflow = get_button('workflow') 

221 update = get_button('update') 

222 create = get_button('create') 

223 export = get_button('export') 

224 delete = get_button('delete') 

225 

226 row0 = html.Div( 

227 className='row', 

228 children=[ 

229 query, 

230 spacer, 

231 search, 

232 spacer, 

233 dropdown, 

234 spacer, 

235 workflow, 

236 spacer, 

237 update, 

238 spacer, 

239 create, 

240 spacer, 

241 export, 

242 spacer, 

243 delete, 

244 ], 

245 ) 

246 row1 = html.Div(className='row-spacer') 

247 row2 = html.Div(id='table-content', children=[]) 

248 searchbar = html.Div( 

249 id='searchbar', className='menubar', children=[row0, row1, row2] 

250 ) 

251 return searchbar 

252 

253 

254def get_dummy_elements(): 

255 # type: () -> List 

256 ''' 

257 Returns a list of all elements with callbacks so that the client will not 

258 throw errors in each tab. 

259 

260 Returns: 

261 list: List of html elements. 

262 ''' 

263 return [ 

264 dcc.Input(className='dummy', id='query', value=None), 

265 dcc.Dropdown(className='dummy', id='dropdown', value=None), 

266 html.Div(className='dummy', id='search-button', n_clicks=None), 

267 html.Div(className='dummy', id='workflow-button', n_clicks=None), 

268 html.Div(className='dummy', id='update-button', n_clicks=None), 

269 html.Div(className='dummy', id='create-button', n_clicks=None), 

270 html.Div(className='dummy', id='export-button', n_clicks=None), 

271 html.Div(className='dummy', id='delete-button', n_clicks=None), 

272 ] 

273 

274 

275def get_configbar(config): 

276 # type: (Dict) -> html.Div 

277 ''' 

278 Get a row of elements used for configuring Hidebound. 

279 

280 Args: 

281 config (dict): Configuration to be displayed. 

282 

283 Returns: 

284 Div: Div with buttons and JSON editor. 

285 ''' 

286 rows = [ 

287 html.Div(id='config', children=[ 

288 get_key_value_card(config, header='config', id_='config-card') 

289 ]) 

290 ] 

291 configbar = html.Div(id='configbar', className='menubar', children=rows) 

292 return configbar 

293 

294 

295def get_progressbar(data): 

296 # type: (dict) -> html.Div 

297 ''' 

298 Creates a progress bar given progress data. 

299 

300 Args: 

301 data (dict): Progress dictionary. 

302 

303 Returns: 

304 Div: Progress bar. 

305 ''' 

306 temp = dict(message='', progress=1.0) # type: dict 

307 if data is None: 

308 data = {} 

309 temp.update(data) 

310 

311 pct = temp['progress'] # type: float 

312 width = f'{pct * 100:.0f}%' 

313 

314 title = html.Div( 

315 id='progressbar-title', 

316 className='progressbar-title', 

317 children=temp['message'], 

318 ) 

319 body = html.Div( 

320 id='progressbar-body', 

321 className='progressbar-body', 

322 style=dict(width=width) 

323 ) 

324 progressbar = html.Div(id='progressbar', children=[title, body]) 

325 return progressbar 

326 

327 

328# ELEMENTS---------------------------------------------------------------------- 

329def get_dropdown(options): 

330 # type: (List[str]) -> dcc.Dropdown 

331 ''' 

332 Gets html dropdown element with given options. 

333 

334 Args: 

335 options (list[str]): List of options. 

336 

337 Raises: 

338 TypeError: If options is not a list. 

339 TypeError: If any option is not a string. 

340 

341 Returns: 

342 Dropdown: Dropdown element. 

343 ''' 

344 if not isinstance(options, list): 

345 msg = f'{options} is not a list.' 

346 raise TypeError(msg) 

347 

348 illegal = list(filter(lambda x: not isinstance(x, str), options)) 

349 if len(illegal) > 0: 

350 msg = f'{illegal} are not strings.' 

351 raise TypeError(msg) 

352 

353 return dcc.Dropdown( 

354 id='dropdown', 

355 className='col dropdown', 

356 value=options[0], 

357 options=[{'label': x, 'value': x} for x in options], 

358 placeholder=options[0], 

359 optionHeight=20, 

360 style={ 

361 'background': COLOR_SCHEME['grey1'], 

362 'color': COLOR_SCHEME['light1'], 

363 'border': '0px', 

364 'width': '100px', 

365 } 

366 ) 

367 

368 

369def get_button(title): 

370 # type: (str) -> html.Button 

371 ''' 

372 Get a html button with a given title. 

373 

374 Args: 

375 title (str): Title of button. 

376 

377 Raises: 

378 TypeError: If title is not a string. 

379 

380 Returns: 

381 Button: Button element. 

382 ''' 

383 if not isinstance(title, str): 

384 msg = f'{title} is not a string.' 

385 raise TypeError(msg) 

386 return html.Button(id=f'{title}-button', children=[title], n_clicks=0) 

387 

388 

389def get_key_value_card(data, header=None, id_='key-value-card', sorting=False): 

390 # type: (Union[Dict, OrderedDict], Optional[str], str, bool) -> html.Div 

391 ''' 

392 Creates a key-value card using the keys and values from the given data. 

393 One key-value pair per row. 

394 

395 Args: 

396 data (dict): Dictionary to be represented. 

397 header (str, optional): Name of header. Default: None. 

398 id_ (str): Name of id property. Default: "key-value-card". 

399 sorting (bool, optional): Whether to sort the output by key. 

400 Default: False. 

401 

402 Returns: 

403 Div: Card with key-value child elements. 

404 ''' 

405 data = blob_etl.BlobETL(data)\ 

406 .set( 

407 predicate=lambda k, v: bool(re.search(r'<list_\d', k)), 

408 key_setter=lambda k, v: re.sub('<list_|>', '', k))\ 

409 .to_flat_dict() 

410 

411 children = [] # type: List[Any] 

412 if header is not None: 

413 header = html.Div( 

414 id=f'{id_}-header', 

415 className='key-value-card-header', 

416 children=[str(header)] 

417 ) 

418 children.append(header) 

419 

420 items = data.items() # type: Any 

421 if sorting: 

422 items = sorted(items) 

423 for i, (k, v) in enumerate(items): 

424 even = i % 2 == 0 

425 klass = 'odd' 

426 if even: 

427 klass = 'even' 

428 

429 key = html.Div( 

430 id=f'{k}-key', className='key-value-card-key', children=[str(k)] 

431 ) 

432 sep = html.Div(className='key-value-card-separator') 

433 val = html.Div( 

434 id=f'{k}-value', className='key-value-card-value', children=[str(v)] 

435 ) 

436 

437 row = html.Div( 

438 id=f'{id_}-row', 

439 className=f'key-value-card-row {klass}', 

440 children=[key, sep, val] 

441 ) 

442 children.append(row) 

443 children[-1].className += ' last' 

444 

445 card = html.Div( 

446 id=f'{id_}', 

447 className='key-value-card', 

448 children=children 

449 ) 

450 return card 

451 

452 

453def get_datatable(data): 

454 # type: (List[Dict]) -> dash_table.DataTable 

455 ''' 

456 Gets a Dash DataTable element using given data. 

457 Assumes dict element has all columns of table as keys. 

458 

459 Args: 

460 data (list[dict]): List of dicts. 

461 

462 Returns: 

463 DataTable: Table of data. 

464 ''' 

465 cols = [] # type: Any 

466 if len(data) > 0: 

467 cols = data[0].keys() 

468 cols = [{'name': x, 'id': x} for x in cols] 

469 error_cols = [ 

470 'asset_error', 

471 'file_error', 

472 'filename_error' 

473 ] 

474 

475 return dash_table.DataTable( 

476 data=data, 

477 columns=cols, 

478 id='datatable', 

479 cell_selectable=False, 

480 editable=False, 

481 css=[ 

482 { 

483 'selector': '.dash-cell div.dash-cell-value', 

484 'rule': '''display: inline; 

485 white-space: inherit; 

486 overflow: inherit; 

487 text-overflow: inherit;''' 

488 } 

489 ], 

490 style_data={ 

491 'whiteSpace': 'normal', 

492 'height': 'auto', 

493 'width': 'auto' 

494 

495 }, 

496 style_data_conditional=[ 

497 { 

498 'if': {'row_index': 'odd'}, 

499 'color': COLOR_SCHEME['light1'], 

500 'background': COLOR_SCHEME['grey1'], 

501 }, 

502 { 

503 'if': {'row_index': 'even'}, 

504 'color': COLOR_SCHEME['light1'], 

505 'background': COLOR_SCHEME['bg'] 

506 }, 

507 { 

508 'if': {'column_id': error_cols}, 

509 'color': COLOR_SCHEME['red2'], 

510 } 

511 ], 

512 style_table={ 

513 'zIndex': '0', 

514 'maxWidth': '99.5vw', 

515 'maxHeight': '95vh', 

516 'overflowX': 'auto', 

517 'overflowY': 'auto', 

518 'padding': '0px 4px 0px 4px', 

519 'borderWidth': '0px 1px 0px 1px', 

520 'borderColor': COLOR_SCHEME['grey1'], 

521 }, 

522 style_header={ 

523 'color': COLOR_SCHEME['light2'], 

524 'background': COLOR_SCHEME['grey1'], 

525 'fontWeight': 'bold', 

526 }, 

527 style_filter={ 

528 'color': COLOR_SCHEME['cyan2'], 

529 'background': COLOR_SCHEME['bg'] 

530 }, 

531 style_cell={ 

532 'textAlign': 'left', 

533 # 'minWidth': '105px', 

534 'maxWidth': '300px', 

535 'borderColor': COLOR_SCHEME['grey1'], 

536 'border': '0px', 

537 'height': '25px', 

538 'padding': '3px 0px 3px 10px' 

539 } 

540 ) 

541 

542 

543def get_asset_graph(data): 

544 # type: (List[Dict]) -> cyto.Cytoscape 

545 ''' 

546 Creates asset graph data for cytoscape component. 

547 

548 Args: 

549 data (list[dict]): List of dicts. 

550 

551 Raises: 

552 KeyError: If asset_valid or asset_path keys not found. 

553 

554 Returns: 

555 Cytoscape: Cytoscape graph. 

556 ''' 

557 data_ = DataFrame(data) # type: DataFrame 

558 cols = ['asset_path', 'asset_valid'] 

559 

560 temp = data_.columns.tolist() 

561 if cols[0] not in temp or cols[1] not in temp: 

562 msg = f'Rows must contain {cols} keys. Keys found: {temp}.' 

563 raise KeyError(msg) 

564 

565 data_ = data_[cols] 

566 keys = data_.asset_path.tolist() 

567 vals = data_.asset_valid.apply(lambda x: f'asset_valid: {x}').tolist() 

568 data_ = dict(zip(keys, vals)) 

569 graph = blob_etl.BlobETL(data_).to_networkx_graph() 

570 

571 edges = [] 

572 for s, t in list(graph.edges): 

573 s = re.sub('"|/asset_valid.*', '', s) 

574 t = re.sub('"|/asset_valid.*', '', t) 

575 edge = dict(group='edges', source=s, target=t) 

576 edges.append(edge) 

577 

578 nodes = [] 

579 for n in list(graph.nodes): 

580 attrs = graph.nodes.get(n) 

581 

582 color = COLOR_SCHEME['cyan2'] 

583 val = attrs.get('value', [None])[0] 

584 if val == 'asset_valid: True': 

585 color = COLOR_SCHEME['green2'] 

586 elif val == 'asset_valid: False': 

587 color = COLOR_SCHEME['red2'] 

588 

589 label = attrs['short_name'] 

590 if not label.startswith('"asset_valid'): 

591 node = dict(id=n, group='nodes', label=label, color=color) 

592 nodes.append(node) 

593 

594 nodes.extend(edges) 

595 data_ = [{'data': x} for x in nodes] 

596 

597 root = data_[0]['data']['id'] 

598 return cyto.Cytoscape( 

599 id='asset-graph', 

600 elements=data_, 

601 layout={'name': 'breadthfirst', 'roots': [root]}, 

602 style=dict( 

603 width='98% !important', 

604 height='98% !important', 

605 position='relative !important' 

606 ), 

607 stylesheet=[ 

608 dict( 

609 selector='node', 

610 style={ 

611 'background-color': 'data(color)', 

612 'color': COLOR_SCHEME['bg'], 

613 'content': 'data(label)', 

614 'padding': '10px 10px 10px 10px', 

615 'shape': 'rectangle', 

616 'text-halign': 'center', 

617 'text-valign': 'center', 

618 'width': 'label', 

619 'height': 'label', 

620 'font-size': '25px', 

621 } 

622 ), 

623 dict( 

624 selector='edge', 

625 style={ 

626 'line-color': COLOR_SCHEME['grey2'], 

627 } 

628 ) 

629 ] 

630 )