Coverage for laskea/config.py: 76.71%

255 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-10 22:19:18 +00:00

1"""Configuration API for laskea.""" 

2import copy 

3import json 

4import os 

5import pathlib 

6import sys 

7from typing import Mapping, no_type_check 

8 

9import jmespath 

10 

11import laskea 

12from laskea import log 

13import laskea.api.jira as api 

14 

15TEMPLATE_EXAMPLE = """\ 

16{ 

17 "table": { 

18 "column": { 

19 "fields": [ 

20 "Key", 

21 "Summary", 

22 "Custom Field Name", 

23 ["Custom Field Other", "Display Name"] 

24 ], 

25 "field_map": { 

26 "key": [ 

27 "key", 

28 "key" 

29 ], 

30 "summary": [ 

31 "summary", 

32 "fields.summary" 

33 ], 

34 "custom field name": [ 

35 "customfield_11501", 

36 "fields.customfield_11501" 

37 ], 

38 "custom field other": [ 

39 "customfield_13901", 

40 "fields.customfield_13901[].value" 

41 ] 

42 }, 

43 "filter_map": { 

44 "key": {}, 

45 "summary": {}, 

46 "custom field name": { 

47 "order": ["keep", "drop", "replace"], 

48 "keep": [ 

49 ["startswith", "ABC-"], 

50 ["contains", "Z"], 

51 ["icontains", "m"], 

52 ["equals", "DEF-42"], 

53 ["endswith", "-123"] 

54 ], 

55 "drop": [ 

56 ["matches", "[A-Z]+-\\d+"] 

57 ], 

58 "replace": [ 

59 ["DEF-", "definition-"] 

60 ] 

61 }, 

62 "custom field other": {} 

63 }, 

64 "lf_only": true, 

65 "join_string": " <br>" 

66 }, 

67 "caption": "$NL$$NL$Table: Search '$QUERY_TEXT$' resulted in $ISSUE_COUNT$ issue$SINGULAR$$PLURAL$s$" 

68 }, 

69 "remote": { 

70 "is_cloud": false, 

71 "user": "", 

72 "token": "", 

73 "base_url": "https://remote-jira-instance.example.com/" 

74 }, 

75 "local": { 

76 "markers": "[[[fill ]]] [[[end]]]", 

77 "quiet": false, 

78 "verbose": false, 

79 "strict": false, 

80 "checksums": false 

81 }, 

82 "excel": { 

83 "mbom": "mbom.xlsm" 

84 }, 

85 "tabulator": { 

86 "overview": { 

87 "base_url": "https://example.com/metrics/", 

88 "path": "$year$/kpi-table-$year$.json", 

89 "years": [2022], 

90 "matrix": [ 

91 ["section", "Section", False, "L"], 

92 ["name", "Name", False, "L"], 

93 ["unit", "Unit", False, "C"], 

94 ["all", "ALL", True, "R"], 

95 ["pr1", "PR1", True, "R"], 

96 ["pr2", "PR2", True, "R"], 

97 ["pr3", "PR3", True, "R"], 

98 ["description", "Description", False, "L"] 

99 ] 

100 }, 

101 "metrics": { 

102 "base_url": "https://example.com/metrics/", 

103 "paths": { 

104 "review_effectivity": "$year$/review_effectivity/kpi-review_effectivity-per_product-report-$year$.json", 

105 "sprint_effectivity": "$year$/sprint_effectivity/kpi-sprint_effectivity-per_product-report-$year$.json", 

106 "task_traceability": "$year$/task_traceability/kpi-task_traceability-per_product-report-$year$.json", 

107 }, 

108 "years": [2021, 2022], 

109 "matrix": [ 

110 ["month", "Month", False, "L"], 

111 ["all", "ALL", True, "R"], 

112 ["pr1", "PR1", True, "R"], 

113 ["pr2", "PR2", True, "R"], 

114 ["pr3", "PR3", True, "R"], 

115 ["trend_all", "±ALL", True, "R"], 

116 ["trend_pr1", "±PR1", True, "R"], 

117 ["trend_pr2", "±PR2", True, "R"], 

118 ["trend_pr3", "±PR3", True, "R"] 

119 ] 

120 } 

121 } 

122} 

123""" 

124 

125 

126def generate_template() -> str: 

127 """Return template of a well-formed JSON configuration.""" 

128 return TEMPLATE_EXAMPLE 

129 

130 

131@no_type_check 

132def load_configuration(configuration: dict[str, object]) -> dict[str, str]: 

133 """LaterAlligator.""" 

134 if not configuration: 

135 log.warning('Warning: Requested load from empty configuration') 

136 return {} 

137 

138 source_of = {} 

139 

140 column_fields = jmespath.search('table.column.fields', configuration) 

141 if column_fields: 

142 source_of['column_fields'] = 'config' 

143 api.BASE_COL_FIELDS = copy.deepcopy(column_fields) 

144 column_fields = os.getenv(f'{laskea.APP_ENV}_COL_FIELDS', '') 

145 if column_fields: 145 ↛ 146line 145 didn't jump to line 146, because the condition on line 145 was never true

146 source_of['column_fields'] = 'env' 

147 api.BASE_COL_FIELDS = json.loads(column_fields) 

148 

149 field_map = jmespath.search('table.column.field_map', configuration) 

150 if field_map: 

151 source_of['field_map'] = 'config' 

152 api.BASE_COL_MAPS = copy.deepcopy(field_map) 

153 field_map = os.getenv(f'{laskea.APP_ENV}_COL_MAPS', '') 

154 if field_map: 154 ↛ 155line 154 didn't jump to line 155, because the condition on line 154 was never true

155 source_of['field_map'] = 'env' 

156 api.BASE_COL_MAPS = json.loads(field_map) 

157 

158 filter_map = jmespath.search('table.column.filter_map', configuration) 

159 if filter_map: 

160 source_of['filter_map'] = 'config' 

161 api.BASE_COL_FILTERS = copy.deepcopy(filter_map) 

162 filter_map = os.getenv(f'{laskea.APP_ENV}_COL_FILTERS', '') 

163 if filter_map: 163 ↛ 164line 163 didn't jump to line 164, because the condition on line 163 was never true

164 source_of['filter_map'] = 'env' 

165 api.BASE_COL_FILTERS = json.loads(filter_map) 

166 

167 lf_only = jmespath.search('table.column.lf_only', configuration) 

168 if lf_only: 

169 source_of['lf_only'] = 'config' 

170 api.BASE_LF_ONLY = lf_only 

171 lf_only = os.getenv(f'{laskea.APP_ENV}_LF_ONLY', '') 

172 if lf_only: 172 ↛ 173line 172 didn't jump to line 173, because the condition on line 172 was never true

173 source_of['lf_only'] = 'env' 

174 api.BASE_LF_ONLY = lf_only 

175 

176 join_string = jmespath.search('table.column.join_string', configuration) 

177 if join_string: 

178 source_of['join_string'] = 'config' 

179 api.BASE_JOIN_STRING = join_string 

180 join_string = os.getenv(f'{laskea.APP_ENV}_JOIN_STRING', '') 

181 if join_string: 181 ↛ 182line 181 didn't jump to line 182, because the condition on line 181 was never true

182 source_of['join_string'] = 'env' 

183 api.BASE_JOIN_STRING = join_string 

184 

185 caption = jmespath.search('table.caption', configuration) 

186 if caption: 

187 source_of['caption'] = 'config' 

188 api.BASE_CAPTION = caption 

189 caption = os.getenv(f'{laskea.APP_ENV}_CAPTION', '') 

190 if caption: 190 ↛ 191line 190 didn't jump to line 191, because the condition on line 190 was never true

191 source_of['caption'] = 'env' 

192 api.BASE_CAPTION = caption 

193 

194 remote_user = jmespath.search('remote.user', configuration) 

195 if remote_user: 195 ↛ 196line 195 didn't jump to line 196, because the condition on line 195 was never true

196 source_of['remote_user'] = 'config' 

197 api.BASE_USER = remote_user 

198 remote_user = os.getenv(f'{laskea.APP_ENV}_USER', '') 

199 if remote_user: 199 ↛ 203line 199 didn't jump to line 203, because the condition on line 199 was never false

200 source_of['remote_user'] = 'env' 

201 api.BASE_USER = remote_user 

202 

203 remote_token = jmespath.search('remote.token', configuration) 

204 if remote_token: 

205 source_of['remote_token'] = 'config' # nosec 

206 api.BASE_TOKEN = remote_token 

207 remote_token = os.getenv(f'{laskea.APP_ENV}_TOKEN', '') 

208 if remote_token: 208 ↛ 212line 208 didn't jump to line 212, because the condition on line 208 was never false

209 source_of['remote_token'] = 'env' # nosec 

210 api.BASE_TOKEN = remote_token 

211 

212 remote_base_url = jmespath.search('remote.base_url', configuration) 

213 if remote_base_url: 

214 source_of['remote_base_url'] = 'config' 

215 api.BASE_URL = remote_base_url 

216 remote_base_url = os.getenv(f'{laskea.APP_ENV}_BASE_URL', '') 

217 if remote_base_url: 217 ↛ 221line 217 didn't jump to line 221, because the condition on line 217 was never false

218 source_of['remote_base_url'] = 'env' 

219 api.BASE_URL = remote_base_url 

220 

221 local_markers = jmespath.search('local.markers', configuration) 

222 if local_markers: 

223 source_of['local_markers'] = 'config' 

224 laskea.BASE_MARKERS = local_markers 

225 local_markers = os.getenv(f'{laskea.APP_ENV}_MARKERS', '') 

226 if local_markers: 226 ↛ 227line 226 didn't jump to line 227, because the condition on line 226 was never true

227 source_of['local_markers'] = 'env' 

228 laskea.BASE_MARKERS = local_markers 

229 

230 verbose = bool(jmespath.search('local.verbose', configuration)) 

231 if verbose: 231 ↛ 232line 231 didn't jump to line 232, because the condition on line 231 was never true

232 source_of['verbose'] = 'config' 

233 laskea.DEBUG = verbose 

234 verbose = bool(os.getenv(f'{laskea.APP_ENV}_DEBUG', '')) 

235 if verbose: 235 ↛ 236line 235 didn't jump to line 236, because the condition on line 235 was never true

236 source_of['verbose'] = 'env' 

237 laskea.DEBUG = verbose 

238 

239 is_cloud = bool(jmespath.search('remote.is_cloud', configuration)) 

240 if is_cloud: 240 ↛ 241line 240 didn't jump to line 241, because the condition on line 240 was never true

241 source_of['is_cloud'] = 'config' 

242 laskea.IS_CLOUD = is_cloud 

243 is_cloud = bool(os.getenv(f'{laskea.APP_ENV}_IS_CLOUD', '')) 

244 if is_cloud: 244 ↛ 248line 244 didn't jump to line 248, because the condition on line 244 was never false

245 source_of['is_cloud'] = 'env' 

246 laskea.IS_CLOUD = is_cloud 

247 

248 strict = bool(jmespath.search('local.strict', configuration)) 

249 if strict: 249 ↛ 250line 249 didn't jump to line 250, because the condition on line 249 was never true

250 source_of['strict'] = 'config' 

251 laskea.STRICT = strict 

252 strict = bool(os.getenv(f'{laskea.APP_ENV}_STRICT', '')) 

253 if strict: 253 ↛ 254line 253 didn't jump to line 254, because the condition on line 253 was never true

254 source_of['strict'] = 'env' 

255 laskea.STRICT = strict 

256 

257 checksums = bool(jmespath.search('local.checksums', configuration)) 

258 if checksums: 258 ↛ 259line 258 didn't jump to line 259, because the condition on line 258 was never true

259 source_of['checksums'] = 'config' 

260 laskea.CHECKSUMS = checksums 

261 checksums = bool(os.getenv(f'{laskea.APP_ENV}_CHECKSUMS', '')) 

262 if checksums: 262 ↛ 263line 262 didn't jump to line 263, because the condition on line 262 was never true

263 source_of['checksums'] = 'env' 

264 laskea.CHECKSUMS = checksums 

265 

266 quiet = bool(jmespath.search('local.quiet', configuration)) 

267 if quiet: 267 ↛ 268line 267 didn't jump to line 268, because the condition on line 267 was never true

268 source_of['quiet'] = 'config' 

269 laskea.QUIET = quiet 

270 if source_of['verbose'] == 'config': 

271 laskea.DEBUG = quiet 

272 quiet = bool(os.getenv(f'{laskea.APP_ENV}_QUIET', '')) 

273 if quiet: 273 ↛ 274line 273 didn't jump to line 274, because the condition on line 273 was never true

274 source_of['quiet'] = 'env' 

275 laskea.QUIET = quiet 

276 source_of['verbose'] = 'env' 

277 laskea.DEBUG = quiet 

278 

279 if 'tabulator' in configuration: 279 ↛ 280line 279 didn't jump to line 280, because the condition on line 279 was never true

280 laskea.TABULATOR = copy.deepcopy(configuration['tabulator']) 

281 

282 return source_of 

283 

284 

285@no_type_check 

286def discover_configuration(conf: str) -> tuple[dict[str, object], str]: 

287 """Try to retrieve the configuration following the "(explicit, local, parents, home) 

288 first wun wins" strategy.""" 

289 configuration = None 

290 if conf: 

291 cp = pathlib.Path(conf) 

292 if not cp.is_file() or not cp.stat().st_size: 

293 print('Given configuration path is no file or empty', file=sys.stderr) 

294 sys.exit(2) 

295 if not laskea.QUIET: 295 ↛ 297line 295 didn't jump to line 297, because the condition on line 295 was never false

296 print(f'Reading configuration file {cp} as requested...', file=sys.stderr) 

297 with cp.open(encoding=laskea.ENCODING) as handle: 

298 configuration = json.load(handle) 

299 else: 

300 cn = laskea.DEFAULT_CONFIG_NAME 

301 cwd = pathlib.Path.cwd().resolve() 

302 for pp in (cwd, *cwd.parents): 302 ↛ 311line 302 didn't jump to line 311, because the loop on line 302 didn't complete

303 cp = pp / cn 

304 if cp.is_file() and cp.stat().st_size: 

305 if not laskea.QUIET: 305 ↛ 307line 305 didn't jump to line 307, because the condition on line 305 was never false

306 print(f'Reading from discovered configuration path {cp}', file=sys.stderr) 

307 with cp.open() as handle: 

308 configuration = json.load(handle) 

309 return configuration, str(cp) 

310 

311 cp = pathlib.Path.home() / laskea.DEFAULT_CONFIG_NAME 

312 if cp.is_file() and cp.stat().st_size: 

313 if not laskea.QUIET: 

314 print( 

315 f'Reading configuration file {cp} from home directory at {pathlib.Path.home()} ...', 

316 file=sys.stderr, 

317 ) 

318 with cp.open() as handle: 

319 configuration = json.load(handle) 

320 return configuration, str(cp) 

321 

322 if not laskea.QUIET: 

323 print( 

324 f'User home configuration path to {cp} is no file or empty - ignoring configuration data', 

325 file=sys.stderr, 

326 ) 

327 

328 return configuration, str(cp) 

329 

330 

331@no_type_check 

332def report_context(command: str, transaction_mode: str, vector: list[str]) -> None: 

333 """DRY.""" 

334 if laskea.QUIET: 

335 return 

336 print(f'Command: ({command})', file=sys.stderr) 

337 print(f'- Transaction mode: ({transaction_mode})', file=sys.stderr) 

338 print('Environment(variable values):', file=sys.stderr) 

339 app_env_user = f'{laskea.APP_ENV}_USER' 

340 app_env_token = f'{laskea.APP_ENV}_TOKEN' 

341 app_env_base_url = f'{laskea.APP_ENV}_BASE_URL' 

342 app_env_col_fields = f'{laskea.APP_ENV}_COL_FIELDS' 

343 app_env_col_maps = f'{laskea.APP_ENV}_COL_MAPS' 

344 app_env_markers = f'{laskea.APP_ENV}_MARKERS' 

345 app_env_lf_only = f'{laskea.APP_ENV}_LF_ONLY' 

346 app_env_caption = f'{laskea.APP_ENV}_CAPTION' 

347 app_env_join_string = f'{laskea.APP_ENV}_JOIN_STRING' 

348 empty = '' 

349 print(f'- {laskea.APP_ENV}_USER: ({os.getenv(app_env_user, empty)})', file=sys.stderr) 

350 print( 

351 f'- {laskea.APP_ENV}_TOKEN: ({laskea.MASK_DISPLAY if len(os.getenv(app_env_token, empty)) else empty})', 

352 file=sys.stderr, 

353 ) 

354 print(f'- {laskea.APP_ENV}_BASE_URL: ({os.getenv(app_env_base_url, empty)})', file=sys.stderr) 

355 print(f'- {laskea.APP_ENV}_COL_FIELDS: ({os.getenv(app_env_col_fields, empty)})', file=sys.stderr) 

356 print(f'- {laskea.APP_ENV}_COL_MAPS: ({os.getenv(app_env_col_maps, empty)})', file=sys.stderr) 

357 print(f'- {laskea.APP_ENV}_MARKERS: ({os.getenv(app_env_markers, empty)})', file=sys.stderr) 

358 print(f'- {laskea.APP_ENV}_LF_ONLY: ({os.getenv(app_env_lf_only, empty)})', file=sys.stderr) 

359 print(f'- {laskea.APP_ENV}_CAPTION: ({os.getenv(app_env_caption, empty)})', file=sys.stderr) 

360 print(f'- {laskea.APP_ENV}_JOIN_STRING: ({os.getenv(app_env_join_string, empty)})', file=sys.stderr) 

361 print('Effective(variable values):', file=sys.stderr) 

362 print(f'- RemoteUser: ({api.BASE_USER})', file=sys.stderr) 

363 print(f'- RemoteToken: ({"*" * len(api.BASE_PASS)})', file=sys.stderr) 

364 print(f'- RemoteBaseURL: ({api.BASE_URL})', file=sys.stderr) 

365 print(f'- ColumnFields(table): ({api.BASE_COL_FIELDS})', file=sys.stderr) 

366 print(f'- ColumnMaps(remote->table): ({api.BASE_COL_MAPS})', file=sys.stderr) 

367 print(f'- ColumnFilters(remote->table): ({api.BASE_COL_FILTERS})', file=sys.stderr) 

368 print(f'- Markers(pattern): ({laskea.BASE_MARKERS})', file=sys.stderr) 

369 print(f'- lf_only: ({laskea.BASE_LF_ONLY})', file=sys.stderr) 

370 print(f'- caption: ({laskea.BASE_CAPTION})', file=sys.stderr) 

371 print(f'- join_string: ({laskea.BASE_JOIN_STRING})', file=sys.stderr) 

372 print(f'- CallVector: ({vector})', file=sys.stderr) 

373 

374 

375@no_type_check 

376def report_sources_of_effective_configuration(source_of: dict[str, str], header: str) -> None: 

377 """DRY.""" 

378 if laskea.QUIET: 

379 return 

380 print(header, file=sys.stderr) 

381 print('# --- BEGIN ---', file=sys.stderr) 

382 print(json.dumps(source_of, indent=2), file=sys.stderr) 

383 print('# --- E N D ---', file=sys.stderr) 

384 

385 

386@no_type_check 

387def safe_report_configuration(configuration: dict[str, object], header: str) -> None: 

388 """DRY.""" 

389 if laskea.QUIET: 

390 return 

391 print(header, file=sys.stderr) 

392 print('# --- BEGIN ---', file=sys.stderr) 

393 fake_configuration = copy.deepcopy(configuration) 

394 if jmespath.search('remote.token', fake_configuration): 

395 fake_configuration['remote']['token'] = laskea.MASK_DISPLAY # noqa 

396 print(json.dumps(fake_configuration, indent=2), file=sys.stderr) 

397 print('# --- E N D ---', file=sys.stderr) 

398 

399 

400@no_type_check 

401def create_and_report_effective_configuration(header: str) -> None: 

402 """DRY.""" 

403 if laskea.QUIET: 

404 return 

405 effective = { 

406 'table': { 

407 'column': { 

408 'fields': copy.deepcopy(api.BASE_COL_FIELDS), 

409 'field_map': copy.deepcopy(api.BASE_COL_MAPS), 

410 'filter_map': copy.deepcopy(api.BASE_COL_FILTERS), 

411 'lf_only': api.BASE_LF_ONLY, 

412 'join_string': api.BASE_JOIN_STRING, 

413 }, 

414 'caption': api.BASE_CAPTION, 

415 }, 

416 'remote': { 

417 'is_cloud': api.BASE_IS_CLOUD, 

418 'user': api.BASE_USER, 

419 'token': '', 

420 'base_url': api.BASE_URL, 

421 }, 

422 'local': { 

423 'markers': laskea.BASE_MARKERS, 

424 'quiet': laskea.QUIET, 

425 'verbose': laskea.DEBUG, 

426 'strict': laskea.STRICT, 

427 'checksums': laskea.CHECKSUMS, 

428 }, 

429 } 

430 safe_report_configuration(effective, header) 

431 

432 

433def process(conf: str, options: Mapping[str, bool]) -> None: 

434 """SPOC.""" 

435 configuration, cp = discover_configuration(conf if isinstance(conf, str) else '') 

436 

437 verbose = bool(options.get('verbose', '')) 

438 if configuration is not None: 438 ↛ 454line 438 didn't jump to line 454, because the condition on line 438 was never false

439 if laskea.DEBUG or verbose: 

440 safe_report_configuration(configuration, f'Loaded configuration from {cp}:') 

441 

442 source_of = load_configuration(configuration) 

443 

444 if laskea.DEBUG or verbose: 

445 report_sources_of_effective_configuration(source_of, f'Configuration source after loading from {cp}:') 

446 

447 if not laskea.QUIET: 447 ↛ 450line 447 didn't jump to line 450, because the condition on line 447 was never false

448 print('Configuration interface combined file, environment, and commandline values!', file=sys.stderr) 

449 

450 create_and_report_effective_configuration( 

451 f'Effective configuration combining {cp}, environment variables, and defaults:' 

452 ) 

453 

454 if laskea.DEBUG or verbose: 

455 log.info(f'Upstream JIRA instance is addressed per {"cloud" if api.BASE_IS_CLOUD else "server"} rules')