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

131 statements  

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

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

2 

3from collections import OrderedDict 

4from dataclasses import dataclass 

5from pathlib import Path 

6from pprint import pformat 

7import base64 

8import json 

9import os 

10import re 

11import traceback 

12 

13from flask.testing import FlaskClient 

14import flask 

15import jinja2 

16import lunchbox.tools as lbt 

17import requests 

18import rolling_pin.blob_etl as rpb 

19import yaml 

20 

21import hidebound.core.logging as hblog 

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

23 

24 

25HOST = '0.0.0.0' 

26PORT = 8080 

27 

28 

29@dataclass 

30class EndPoints: 

31 ''' 

32 A convenience class for API endpoints. 

33 ''' 

34 host = HOST 

35 port = PORT 

36 api = f'http://{HOST}:{PORT}/api' 

37 init = f'http://{HOST}:{PORT}/api/initialize' 

38 update = f'http://{HOST}:{PORT}/api/update' 

39 create = f'http://{HOST}:{PORT}/api/create' 

40 export = f'http://{HOST}:{PORT}/api/export' 

41 delete = f'http://{HOST}:{PORT}/api/delete' 

42 read = f'http://{HOST}:{PORT}/api/read' 

43 search = f'http://{HOST}:{PORT}/api/search' 

44 workflow = f'http://{HOST}:{PORT}/api/workflow' 

45 progress = f'http://{HOST}:{PORT}/api/progress' 

46# ------------------------------------------------------------------------------ 

47 

48 

49def render_template(filename, parameters): 

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

51 ''' 

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

53 

54 Args: 

55 filename (str): Filename of template. 

56 parameters (dict): Dictionary of template parameters. 

57 

58 Returns: 

59 bytes: HTML. 

60 ''' 

61 tempdir = lbt.relative_path(__file__, '../../../templates').as_posix() 

62 env = jinja2.Environment( 

63 loader=jinja2.FileSystemLoader(tempdir), 

64 keep_trailing_newline=True 

65 ) 

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

67 return output 

68 

69 

70def parse_json_file_content(raw_content): 

71 # type: (bytes) -> Dict 

72 ''' 

73 Parses JSON file content as supplied by HTML request. 

74 

75 Args: 

76 raw_content (bytes): Raw JSON file content. 

77 

78 Raises: 

79 ValueError: If header is invalid. 

80 JSONDecodeError: If JSON is invalid. 

81 

82 Returns: 

83 dict: JSON content or reponse dict with error. 

84 ''' 

85 header, content = raw_content.split(',') # type: ignore 

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

87 if temp != 'json': 

88 msg = f'File header is not JSON. Header: {header}.' # type: ignore 

89 raise ValueError(msg) 

90 

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

92 return json.loads(output) 

93 

94 

95def error_to_response(error): 

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

97 ''' 

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

99 

100 Args: 

101 error (Exception): Error to be formatted. 

102 

103 Returns: 

104 flask.Response: Flask response. 

105 ''' 

106 args = [] # type: Any 

107 for arg in error.args: 

108 if hasattr(arg, 'items'): 

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

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

111 else: 

112 args.append(str(arg)) 

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

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

115 klass = error.__class__.__name__ 

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

117 return flask.Response( 

118 response=json.dumps(dict( 

119 error=error.__class__.__name__, 

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

121 message=msg, 

122 code=500, 

123 traceback=traceback.format_exc(), 

124 )), 

125 mimetype='application/json', 

126 status=500, 

127 ) 

128 

129 

130# SETUP------------------------------------------------------------------------- 

131def setup_hidebound_directories(root): 

132 # type: (Union[str, Path]) -> None 

133 ''' 

134 Creates [root]/ingress, [root]/hidebound and [root]/archive directories. 

135 

136 Args: 

137 root (str or Path): Root directory. 

138 ''' 

139 for folder in ['ingress', 'hidebound', 'archive']: 

140 os.makedirs(Path(root, folder), exist_ok=True) 

141 

142 

143# ERRORS------------------------------------------------------------------------ 

144def get_config_error(): 

145 # type: () -> flask.Response 

146 ''' 

147 Convenience function for returning a config error response. 

148 

149 Returns: 

150 Response: Config error. 

151 ''' 

152 msg = 'Please supply a config dictionary.' 

153 error = TypeError(msg) 

154 return error_to_response(error) 

155 

156 

157def get_initialization_error(): 

158 # type: () -> flask.Response 

159 ''' 

160 Convenience function for returning a initialization error response. 

161 

162 Returns: 

163 Response: Initialization error. 

164 ''' 

165 msg = 'Database not initialized. Please call initialize.' 

166 error = RuntimeError(msg) 

167 return error_to_response(error) 

168 

169 

170def get_update_error(): 

171 # type: () -> flask.Response 

172 ''' 

173 Convenience function for returning a update error response. 

174 

175 Returns: 

176 Response: Update error. 

177 ''' 

178 msg = 'Database not updated. Please call update.' 

179 error = RuntimeError(msg) 

180 return error_to_response(error) 

181 

182 

183def get_read_error(): 

184 ''' 

185 Convenience function for returning a read error response. 

186 

187 Returns: 

188 Response: Update error. 

189 ''' 

190 msg = 'Please supply valid read params in the form ' 

191 msg += '{"group_by_asset": BOOL}.' 

192 error = ValueError(msg) 

193 return error_to_response(error) 

194 

195 

196def get_search_error(): 

197 # type: () -> flask.Response 

198 ''' 

199 Convenience function for returning a search error response. 

200 

201 Returns: 

202 Response: Update error. 

203 ''' 

204 msg = 'Please supply valid search params in the form ' 

205 msg += '{"query": SQL query, "group_by_asset": BOOL}.' 

206 error = ValueError(msg) 

207 return error_to_response(error) 

208 

209 

210def get_connection_error(): 

211 # type: () -> flask.Response 

212 ''' 

213 Convenience function for returning a database connection error response. 

214 

215 Returns: 

216 Response: Connection error. 

217 ''' 

218 msg = 'Database not connected.' 

219 error = RuntimeError(msg) 

220 return error_to_response(error) 

221 

222 

223# DASH-TOOLS-------------------------------------------------------------------- 

224def get_progress(logpath=hblog.PROGRESS_LOG_PATH): 

225 # type: (Union[str, Path]) -> dict 

226 ''' 

227 Gets current progress state. 

228 

229 Args: 

230 logpath (str or Path, optional): Filepath of progress log. 

231 Default: PROGRESS_LOG_PATH. 

232 

233 Returns: 

234 dict: Progess. 

235 ''' 

236 state = dict(progress=1.0, message='unknown state') 

237 state.update(hblog.get_progress(logpath)) 

238 return state 

239 

240 

241def request(store, url, params=None, client=requests): 

242 # type: (dict, str, Optional[dict], Any) -> dict 

243 ''' 

244 Execute search against database and update store with response. 

245 Sets store['content'] to response if there is an error. 

246 

247 Args: 

248 store (dict): Dash store. 

249 url (str): API endpoint. 

250 params (dict, optional): Request paramaters. Default: None. 

251 client (object, optional): Client. Default: requests module. 

252 

253 Returns: 

254 dict: Store. 

255 ''' 

256 params_ = None 

257 if params is not None: 

258 params_ = json.dumps(params) 

259 headers = {'Content-Type': 'application/json'} 

260 response = client.post(url, json=params_, headers=headers) 

261 code = response.status_code 

262 if isinstance(client, FlaskClient): 

263 response = response.json 

264 else: 

265 response = response.json() # pragma: no cover 

266 if code < 200 or code >= 300: 

267 store['content'] = response 

268 store['ready'] = True 

269 return response 

270 

271 

272def search(store, query, group_by_asset, client=requests): 

273 # type: (dict, str, bool, Any) -> dict 

274 ''' 

275 Execute search against database and update given store with response. 

276 

277 Args: 

278 store (dict): Dash store. 

279 query (str): Query string. 

280 group_by_asset (bool): Whether to group the search by asset. 

281 client (object, optional): Client. Default: requests module. 

282 

283 Returns: 

284 dict: Store. 

285 ''' 

286 params = dict(query=query, group_by_asset=group_by_asset) 

287 store['content'] = request(store, EndPoints().search, params, client) 

288 store['query'] = query 

289 store['ready'] = True 

290 return store 

291 

292 

293def format_config( 

294 config, redact_regex='(_key|_id|_token|url)$', redact_hash=False 

295): 

296 # type: (Dict[str, Any], str, bool) -> OrderedDict[str, Any] 

297 ''' 

298 Redacts credentials of config and formats it for display in component. 

299 

300 Args: 

301 config (dict): Configuration dictionary. 

302 redact_regex (str, optional): Regular expression that matches keys, 

303 whose values are to be redacted. Default: "(_key|_id|_token|url)$". 

304 redact_hash (bool, optional): Whether to redact values with the string 

305 "REDACTED" or a hash of the value. Default: False. 

306 

307 Returns: 

308 OrderedDict: Formatted config. 

309 ''' 

310 def redact(key, value, as_hash): 

311 if as_hash: 

312 return 'hash-' + str(hash(value)).lstrip('-') 

313 return 'REDACTED' 

314 

315 def predicate(key, value): 

316 if re.search(redact_regex, key): 

317 return True 

318 return False 

319 

320 config = rpb.BlobETL(config) \ 

321 .set(predicate, value_setter=lambda k, v: redact(k, v, redact_hash)) \ 

322 .to_dict() 

323 output = OrderedDict() 

324 keys = [ 

325 'ingress_directory', 

326 'staging_directory', 

327 'include_regex', 

328 'exclude_regex', 

329 'write_mode', 

330 'redact_regex', 

331 'redact_hash', 

332 'specification_files', 

333 'workflow', 

334 'dask', 

335 'exporters', 

336 'webhooks', 

337 ] 

338 all_ = set(config.keys()) 

339 cross = all_.intersection(keys) 

340 diff = sorted(list(all_.difference(cross))) 

341 keys = list(filter(lambda x: x in cross, keys)) + diff 

342 for key in keys: 

343 val = re.sub(r'\.\.\.$', '', yaml.safe_dump(config[key])).rstrip('\n') 

344 output[key] = val 

345 return output