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
« 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
4from copy import deepcopy
5from pprint import pformat
6import base64
7import json
8import re
9import traceback
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
19import shekels.core.config as cfg
20import shekels.core.data_tools as sdt
21import shekels.server.components as svc
22# ------------------------------------------------------------------------------
25TEMPLATE_DIR = lbt.relative_path(__file__, '../../../templates').as_posix()
28def error_to_dict(error):
29 # type: (Exception) -> Dict[str, Any]
30 '''
31 Convenience function for formatting a given exception as a dictionary.
33 Args:
34 error (Exception): Error to be formatted.
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 )
59def error_to_response(error):
60 # type: (Exception) -> flask.Response
61 '''
62 Convenience function for formatting a given exception as a Flask Response.
64 Args:
65 error (Exception): Error to be formatted.
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 )
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.
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'.
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()
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
103def parse_json_file_content(raw_content):
104 # type: (str) -> Dict
105 '''
106 Parses JSON file content as supplied by HTML request.
108 Args:
109 raw_content (str): Raw JSON file content.
111 Raises:
112 ValueError: If header is invalid.
113 JSONDecodeError: If JSON is invalid.
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)
124 output = base64.b64decode(content).decode('utf-8')
125 return jsonc.JsonComment().loads(output)
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.
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
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.
151 Args:
152 store (dict): Dash store.
153 key (str): Store key.
155 Raises:
156 PreventUpdate: If key is not in store.
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
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.
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:
178 * /config
179 * /config/search
180 * /api/initialize
181 * /api/update
182 * /api/search
184 Args:
185 store (dict): Dash store.
186 config (bool, optional): Whether the component is for the config tab.
187 Default: False.
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
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.
227 Args:
228 value (str): SQL query.
229 store (dict): Dash store.
230 app (dash.Dash): Dash app.
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
244def config_edit_event(value, store, app):
245 # type: (dict, dict, dash.Dash) -> dict
246 '''
247 Saves given edits to store.
249 Args:
250 value (dict): Config table.
251 store (dict): Dash store.
252 app (dash.Dash): Dash app.
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
273def data_query_event(value, store, app):
274 # type: (str, dict, dash.Dash) -> dict
275 '''
276 Updates given store given a data query.
278 Args:
279 value (str): SQL query.
280 store (dict): Dash store.
281 app (dash.Dash): Dash app.
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
291def init_event(value, store, app):
292 # type: (None, dict, dash.Dash) -> dict
293 '''
294 Initializes app database.
296 Args:
297 value (None): Ignored.
298 store (dict): Dash store.
299 app (dash.Dash): Dash app.
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
312def update_event(value, store, app):
313 # type: (None, dict, dash.Dash) -> dict
314 '''
315 Update app database.
317 Args:
318 value (None): Ignored.
319 store (dict): Dash store.
320 app (dash.Dash): Dash app.
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
335def upload_event(value, store, app):
336 # type: (str, dict, dash.Dash) -> dict
337 '''
338 Uploads config to app store.
340 Args:
341 value (str): Config.
342 store (dict): Dash store.
343 app (dash.Dash): Dash app.
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
359def save_event(value, store, app):
360 # type: (None, dict, dash.Dash) -> dict
361 '''
362 Save store config to app.api.config path.
364 Args:
365 value (None): Ignore me.
366 store (dict): Dash store.
367 app (dash.Dash): Dash app.
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