Coverage for /home/ubuntu/shekels/python/shekels/server/server_tools.py: 100%

125 statements  

« prev     ^ index     » next       coverage.py v7.1.0, created at 2023-11-15 00:54 +0000

1from typing import Any, Dict, Optional # noqa: F401 

2import dash # noqa: F401 

3 

4from copy import deepcopy 

5from pprint import pformat 

6import base64 

7import json 

8import re 

9import traceback 

10 

11from dash.exceptions import PreventUpdate 

12from schematics.exceptions import DataError 

13import flask 

14import jinja2 

15import jsoncomment as jsonc 

16import lunchbox.tools as lbt 

17import rolling_pin.blob_etl as rpb 

18 

19import shekels.core.config as cfg 

20import shekels.core.data_tools as sdt 

21import shekels.server.components as svc 

22# ------------------------------------------------------------------------------ 

23 

24 

25TEMPLATE_DIR = lbt.relative_path(__file__, '../../../templates').as_posix() 

26 

27 

28def error_to_dict(error): 

29 # type: (Exception) -> Dict[str, Any] 

30 ''' 

31 Convenience function for formatting a given exception as a dictionary. 

32 

33 Args: 

34 error (Exception): Error to be formatted. 

35 

36 Returns: 

37 Dict[str, Any]: Error dictionary. 

38 ''' 

39 args = [] # type: Any 

40 for arg in error.args: 

41 if hasattr(arg, 'items'): 

42 for key, val in arg.items(): 

43 args.append(pformat({key: pformat(val)})) 

44 else: 

45 args.append(str(arg)) 

46 args = [' ' + x for x in args] 

47 args = '\n'.join(args) 

48 klass = error.__class__.__name__ 

49 msg = f'{klass}(\n{args}\n)' 

50 return dict( 

51 error=error.__class__.__name__, 

52 args=list(map(str, error.args)), 

53 message=msg, 

54 code=500, 

55 traceback=traceback.format_exc(), 

56 ) 

57 

58 

59def error_to_response(error): 

60 # type: (Exception) -> flask.Response 

61 ''' 

62 Convenience function for formatting a given exception as a Flask Response. 

63 

64 Args: 

65 error (Exception): Error to be formatted. 

66 

67 Returns: 

68 flask.Response: Flask response. 

69 ''' 

70 return flask.Response( 

71 response=json.dumps(error_to_dict(error)), 

72 mimetype='application/json', 

73 status=500, 

74 ) 

75 

76 

77def render_template(filename, parameters, directory=None): 

78 # type: (str, Dict[str, Any], Optional[str]) -> bytes 

79 ''' 

80 Renders a jinja2 template given by filename with given parameters. 

81 

82 Args: 

83 filename (str): Filename of template. 

84 parameters (dict): Dictionary of template parameters. 

85 directory (str or Path, optional): Templates directory. 

86 Default: '../../../templates'. 

87 

88 Returns: 

89 bytes: HTML. 

90 ''' 

91 tempdir = TEMPLATE_DIR 

92 if directory is not None: 

93 tempdir = lbt.relative_path(__file__, directory).as_posix() 

94 

95 env = jinja2.Environment( 

96 loader=jinja2.FileSystemLoader(tempdir), 

97 keep_trailing_newline=True 

98 ) 

99 output = env.get_template(filename).render(parameters).encode('utf-8') 

100 return output 

101 

102 

103def parse_json_file_content(raw_content): 

104 # type: (str) -> Dict 

105 ''' 

106 Parses JSON file content as supplied by HTML request. 

107 

108 Args: 

109 raw_content (str): Raw JSON file content. 

110 

111 Raises: 

112 ValueError: If header is invalid. 

113 JSONDecodeError: If JSON is invalid. 

114 

115 Returns: 

116 dict: JSON content or reponse dict with error. 

117 ''' 

118 header, content = raw_content.split(',') 

119 temp = header.split('/')[-1].split(';')[0] # type: str 

120 if temp != 'json': 

121 msg = f'File header is not JSON. Header: {header}.' 

122 raise ValueError(msg) 

123 

124 output = base64.b64decode(content).decode('utf-8') 

125 return jsonc.JsonComment().loads(output) 

126 

127 

128def update_store(client, store, endpoint, data=None): 

129 # type (FlaskClient, dict, str, Optional(dict)) -> None 

130 ''' 

131 Updates store with data from given endpoint. 

132 Makes a post call to endpoint with client. 

133 

134 Args: 

135 client (FlaskClient): Flask client instance. 

136 store (dict): Dash store. 

137 endpoint (str): API endpoint. 

138 data (dict, optional): Data to be provided to endpoint request. 

139 ''' 

140 if data is not None: 

141 store[endpoint] = client.post(endpoint, json=json.dumps(data)).json 

142 else: 

143 store[endpoint] = client.post(endpoint).json 

144 

145 

146def store_key_is_valid(store, key): 

147 # type: (dict, str) -> bool 

148 ''' 

149 Determines if given key is in store and does not have an error. 

150 

151 Args: 

152 store (dict): Dash store. 

153 key (str): Store key. 

154 

155 Raises: 

156 PreventUpdate: If key is not in store. 

157 

158 Returns: 

159 bool: True if key exists and does not have an error key. 

160 ''' 

161 if key not in store: 

162 raise PreventUpdate 

163 value = store[key] 

164 if isinstance(value, dict) and 'error' in value: 

165 return False 

166 return True 

167 

168 

169def solve_component_state(store, config=False): 

170 # type (dict) -> Optional(html.Div) 

171 ''' 

172 Solves what component to return given the state of the given store. 

173 

174 Returns a key value table component embedded with a relevant message or error 

175 if a required key is not found in the store, or it contain a dictionary with 

176 am "error" key in it. Those required keys are as follows: 

177 

178 * /config 

179 * /config/search 

180 * /api/initialize 

181 * /api/update 

182 * /api/search 

183 

184 Args: 

185 store (dict): Dash store. 

186 config (bool, optional): Whether the component is for the config tab. 

187 Default: False. 

188 

189 Returns: 

190 Div: Key value table if store values are not present or have errors, 

191 otherwise, none. 

192 ''' 

193 states = [ 

194 ['/config', None], 

195 ['/config/search', None], 

196 ['/api/initialize', 'Please call init or update.'], 

197 ['/api/update', 'Please call update.'], 

198 ['/api/search', None], 

199 ] 

200 if config: 

201 states = states[:2] 

202 states[1][1] = None 

203 for key, message in states: 

204 value = store.get(key) 

205 if message is not None and value is None: 

206 return svc.get_key_value_table( 

207 {'action': message}, 

208 id_='status', 

209 header='status', 

210 ) 

211 elif isinstance(value, dict) and 'error' in value: 

212 return svc.get_key_value_table( 

213 value, 

214 id_='error', 

215 header='error', 

216 key_order=['error', 'message', 'code', 'traceback'], 

217 ) 

218 return None 

219 

220 

221# EVENTS------------------------------------------------------------------------ 

222def config_query_event(value, store, app): 

223 # type: (str, dict, dash.Dash) -> dict 

224 ''' 

225 Updates given store given a config query. 

226 

227 Args: 

228 value (str): SQL query. 

229 store (dict): Dash store. 

230 app (dash.Dash): Dash app. 

231 

232 Returns: 

233 dict: Modified store. 

234 ''' 

235 value = value or 'select * from config' 

236 value = re.sub('from config', 'from data', value, flags=re.I) 

237 try: 

238 store['/config/search'] = sdt.query_dict(app.api.config, value) 

239 except Exception as e: 

240 store['/config/search'] = error_to_response(e).json 

241 return store 

242 

243 

244def config_edit_event(value, store, app): 

245 # type: (dict, dict, dash.Dash) -> dict 

246 ''' 

247 Saves given edits to store. 

248 

249 Args: 

250 value (dict): Config table. 

251 store (dict): Dash store. 

252 app (dash.Dash): Dash app. 

253 

254 Returns: 

255 dict: Modified store. 

256 ''' 

257 new = value['new'] 

258 old_key = value['old']['key'] 

259 config = store.get('/config', deepcopy(app.api.config)) 

260 items = [ 

261 ('/config', config), 

262 ('/config/search', store.get('/config/search', config)), 

263 ] 

264 for key, val in items: 

265 item = rpb.BlobETL(val).to_flat_dict() 

266 if old_key in item: 

267 del item[old_key] 

268 item[new['key']] = new['value'] 

269 store[key] = rpb.BlobETL(item).to_dict() 

270 return store 

271 

272 

273def data_query_event(value, store, app): 

274 # type: (str, dict, dash.Dash) -> dict 

275 ''' 

276 Updates given store given a data query. 

277 

278 Args: 

279 value (str): SQL query. 

280 store (dict): Dash store. 

281 app (dash.Dash): Dash app. 

282 

283 Returns: 

284 dict: Modified store. 

285 ''' 

286 update_store(app.client, store, '/api/search', data={'query': value}) 

287 store['/api/search/query'] = value 

288 return store 

289 

290 

291def init_event(value, store, app): 

292 # type: (None, dict, dash.Dash) -> dict 

293 ''' 

294 Initializes app database. 

295 

296 Args: 

297 value (None): Ignored. 

298 store (dict): Dash store. 

299 app (dash.Dash): Dash app. 

300 

301 Returns: 

302 dict: Modified store. 

303 ''' 

304 update_store(app.client, store, '/api/initialize', data=app.api.config) 

305 if 'error' in store['/api/initialize']: 

306 store['/config'] = store['/api/initialize'] 

307 else: 

308 store['/config'] = deepcopy(app.api.config) 

309 return store 

310 

311 

312def update_event(value, store, app): 

313 # type: (None, dict, dash.Dash) -> dict 

314 ''' 

315 Update app database. 

316 

317 Args: 

318 value (None): Ignored. 

319 store (dict): Dash store. 

320 app (dash.Dash): Dash app. 

321 

322 Returns: 

323 dict: Modified store. 

324 ''' 

325 update_store(app.client, store, '/api/update') 

326 update_store( 

327 app.client, 

328 store, 

329 '/api/search', 

330 data={'query': app.api.config['default_query']} 

331 ) 

332 return store 

333 

334 

335def upload_event(value, store, app): 

336 # type: (str, dict, dash.Dash) -> dict 

337 ''' 

338 Uploads config to app store. 

339 

340 Args: 

341 value (str): Config. 

342 store (dict): Dash store. 

343 app (dash.Dash): Dash app. 

344 

345 Returns: 

346 dict: Modified store. 

347 ''' 

348 try: 

349 config = parse_json_file_content(value) 

350 config = cfg.Config(config) 

351 config.validate() 

352 store['/config'] = config.to_primitive() 

353 store['/config/search'] = deepcopy(store['/config']) 

354 except Exception as error: 

355 store['/config/search'] = error_to_response(error).json 

356 return store 

357 

358 

359def save_event(value, store, app): 

360 # type: (None, dict, dash.Dash) -> dict 

361 ''' 

362 Save store config to app.api.config path. 

363 

364 Args: 

365 value (None): Ignore me. 

366 store (dict): Dash store. 

367 app (dash.Dash): Dash app. 

368 

369 Returns: 

370 dict: Modified store. 

371 ''' 

372 try: 

373 config = store.get('/config', app.api.config) 

374 cfg.Config(config).validate() 

375 with open(app.api.config_path, 'w') as f: 

376 json.dump(config, f, indent=4, sort_keys=True) 

377 except (Exception, DataError) as error: 

378 store['/config'] = deepcopy(app.api.config) 

379 store['/config/search'] = error_to_response(error).json 

380 return store