Coverage for denofo/questionnaire/questionnaire_cli.py: 86%

266 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-09 15:27 +0200

1import curses 

2import argparse 

3import warnings 

4import sys 

5from pydantic import ValidationError, BaseModel 

6from enum import Enum 

7from typing import Any 

8from pathlib import Path 

9from denofo.utils.helpers import add_extension 

10from denofo.converter.convert import convert_to_json 

11from denofo.models import ModelValidError 

12from denofo.utils.constants import SECTIONS, GoQBack 

13from denofo.utils.ncbiTaxDBcheck import check_NCBI_taxDB 

14from denofo.questionnaire.questions import DeNovoQuestionnaire 

15 

16 

17def _show_message(stdscr: curses.window, message: str): 

18 """ 

19 Show a message in the curses window. 

20 

21 :param stdscr: The curses window. 

22 :type stdscr: curses.window 

23 :param message: The message to show. 

24 :type message: str 

25 """ 

26 stdscr.addstr( 

27 5, 

28 0, 

29 f"{message}", 

30 curses.color_pair(3), 

31 ) 

32 stdscr.refresh() 

33 curses.delay_output(2000) # Delay for 2 seconds 

34 stdscr.clear() 

35 

36 

37def valid_input_for_pydmodel( 

38 pydmodel: BaseModel, field_name: str, inp_val: Any 

39) -> bool: 

40 """ 

41 Validate the input value with a certain pydantic model and model field 

42 to ask the user for input again if the input is invalid. 

43 

44 :param pydmodel: The pydantic model. 

45 :type pydmodel: BaseModel 

46 :param field_name: The field name of the model. 

47 :type field_name: str 

48 :param inp_val: The input value to validate. 

49 :type inp_val: Any 

50 :return: True if the input is valid, False otherwise. 

51 :rtype: bool 

52 """ 

53 try: 

54 # pydmodel.validate({field_name: inp_val}) 

55 pydmodel.__pydantic_validator__.validate_assignment( 

56 pydmodel.model_construct(), field_name, inp_val 

57 ) 

58 return True 

59 except UserWarning as w: 

60 warning = w 

61 curses.wrapper(lambda stdscr: _show_message(stdscr, warning)) 

62 return True 

63 except ValidationError as e: 

64 errors = e.errors() 

65 modelValErr = errors[0].get("ctx", dict()).get("error", None) 

66 if isinstance(modelValErr, ModelValidError): 

67 return True 

68 else: 

69 val_err = e 

70 err_msg = ", ".join(val_err.errors()[0]["msg"].split(",")[1:]) 

71 curses.wrapper(lambda stdscr: _show_message(stdscr, err_msg)) 

72 return False 

73 

74 

75def init_colors(stdscr: curses.window): 

76 """ 

77 Initialize the colors for the curses window. 

78 

79 :param stdscr: The curses window. 

80 :type stdscr: curses.window 

81 """ 

82 curses.start_color() 

83 curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_GREEN) 

84 curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_MAGENTA) 

85 curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_RED) 

86 curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_YELLOW) 

87 curses.init_pair(5, curses.COLOR_WHITE, curses.COLOR_BLACK) 

88 

89 

90def enum_choice_selection( 

91 stdscr: curses.window, 

92 question: str, 

93 items: list[Enum], 

94 multi: bool, 

95 selected: list[str], 

96 section_idx: int, 

97 prev_answer: Any, 

98) -> GoQBack | list[str]: 

99 """ 

100 Select an item from a list of items. 

101 

102 :param stdscr: The curses window. 

103 :type stdscr: curses.window 

104 :param question: The question to ask. 

105 :type question: str 

106 :param items: The list of items to choose from. 

107 :type items: list[Enum] 

108 :param multi: If multiple choices are allowed. 

109 :type multi: bool 

110 :param selected: The selected items. 

111 :type selected: list[str] 

112 :param section_idx: The index of the section. 

113 :type section_idx: int 

114 :param prev_answer: The previous answer. 

115 :type prev_answer: Any 

116 :return: The selected item/items. 

117 :rtype: GoQBack | list[str] 

118 """ 

119 current_index = 0 

120 if prev_answer: 

121 if isinstance(prev_answer, str): 

122 prev_answer = [prev_answer] 

123 

124 selected.extend(prev_answer) 

125 

126 while True: 

127 stdscr.clear() 

128 

129 # Display the progress bar 

130 tot_length = 0 

131 for index, section in enumerate(SECTIONS): 

132 centered_str = f"{section}" 

133 

134 if index == section_idx: 

135 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(4)) 

136 else: 

137 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(5)) 

138 

139 tot_length += len(centered_str) 

140 

141 stdscr.addstr(3, 1, f"{question}\n", curses.color_pair(2)) 

142 stdscr.addstr( 

143 4, 

144 1, 

145 "Select with Return/Spacebar and (a)ccept or Right/Left Arrow Key to navigate. " 

146 f"{'One choice.' if not multi else 'Multi-choice.'}", 

147 ) 

148 

149 # Display all the items 

150 for index, item in enumerate(items): 

151 if index == current_index: 

152 stdscr.attron(curses.A_REVERSE) 

153 

154 if item in selected: 

155 stdscr.attron(curses.color_pair(1)) 

156 stdscr.addstr(index + 5, 1, f"{item.value}") 

157 stdscr.attroff(curses.color_pair(1)) 

158 else: 

159 stdscr.addstr(index + 5, 1, f"{item.value}") 

160 

161 if index == current_index: 

162 stdscr.attroff(curses.A_REVERSE) 

163 

164 stdscr.refresh() 

165 key = stdscr.getch() 

166 

167 # Navigate the list 

168 if key == curses.KEY_UP and current_index > 0: 

169 current_index -= 1 

170 elif key == curses.KEY_DOWN and current_index < len(items) - 1: 

171 current_index += 1 

172 elif key in [ord("\n"), ord(" ")]: 

173 # Select/deselect the item 

174 if items[current_index].value in selected: 

175 selected.remove(items[current_index].value) 

176 else: 

177 selected.append(items[current_index].value) 

178 elif key == ord("a") or key == curses.KEY_RIGHT: 

179 if not selected: 

180 stdscr.addstr( 

181 8, 0, "Please select at least one item.", curses.color_pair(3) 

182 ) 

183 stdscr.refresh() 

184 curses.delay_output(1000) # Delay for 1 second 

185 continue 

186 elif not multi and len(selected) > 1: 

187 stdscr.addstr( 

188 8, 0, "Please select only one item.", curses.color_pair(3) 

189 ) 

190 stdscr.refresh() 

191 curses.delay_output(1000) # Delay for 1 second 

192 continue 

193 # Quit the application 

194 break 

195 elif key == curses.KEY_LEFT: 

196 stdscr.clear() 

197 return GoQBack() 

198 

199 

200def get_enum_choice_conversion( 

201 my_enum: Enum, 

202 question: str = "", 

203 multi_choice: bool = False, 

204 section_idx: int = 0, 

205 prev_answer: Any = None, 

206) -> GoQBack | str | list[str]: 

207 """ 

208 Wrapper to get an enum choice from the user. 

209 

210 :param my_enum: The enum to choose from. 

211 :type my_enum: Enum 

212 :param question: The question to ask. 

213 :type question: str 

214 :param multi_choice: If multiple choices are allowed. 

215 :type multi_choice: bool 

216 :param section_idx: The index of the section. 

217 :type section_idx: int 

218 :param prev_answer: The previous answer. 

219 :type prev_answer: Any 

220 :return: The selected item/items. 

221 :rtype: GoQBack | str | list[str] 

222 """ 

223 selected = [] 

224 items = list(my_enum) 

225 

226 enum_answer = curses.wrapper( 

227 lambda stdscr: enum_choice_selection( 

228 stdscr, question, items, multi_choice, selected, section_idx, prev_answer 

229 ) 

230 ) 

231 

232 if isinstance(enum_answer, GoQBack): 

233 return GoQBack() 

234 

235 return selected if multi_choice else selected[0] 

236 

237 

238def custom_entry_insertion( 

239 stdscr: curses.window, 

240 description: str, 

241 multi_entries_lst: list, 

242 multi_choice: bool, 

243 section_idx: int, 

244 prev_answer: Any, 

245) -> str | list[str] | GoQBack: 

246 """ 

247 Ask for custom entry/ies from the user. 

248 

249 :param stdscr: The curses window. 

250 :type stdscr: curses.window 

251 :param description: The description of the question. 

252 :type description: str 

253 :param multi_entries_lst: The list of multiple entries. 

254 :type multi_entries_lst: list 

255 :param multi_choice: If multiple entries are allowed. 

256 :type multi_choice: bool 

257 :param section_idx: The index of the section. 

258 :type section_idx: int 

259 :param prev_answer: The previous answer. 

260 :type prev_answer: Any 

261 :return: The custom entry/ies. 

262 :rtype: str | list[str] | GoQBack 

263 """ 

264 previous_entry = False 

265 

266 if prev_answer: 

267 previous_entry = True 

268 if isinstance(prev_answer, str): 

269 prev_answer = [prev_answer] 

270 multi_entries_lst.extend(prev_answer) 

271 

272 while True: 

273 stdscr.clear() 

274 

275 # Display the progress bar 

276 tot_length = 0 

277 for index, section in enumerate(SECTIONS): 

278 centered_str = f"{section}" 

279 

280 if index == section_idx: 

281 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(4)) 

282 else: 

283 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(5)) 

284 

285 tot_length += len(centered_str) 

286 

287 # Display the description 

288 stdscr.addstr( 

289 2, 

290 1, 

291 f"{description if description else 'Please provide your custom entry:'}\n", 

292 curses.color_pair(2), 

293 ) 

294 stdscr.addstr( 

295 3, 

296 1, 

297 "Press Enter to submit custom entry or navigate with Right/Left Arrow Key between questions.", 

298 ) 

299 

300 # Show the already stored entries 

301 if previous_entry: 

302 custom_entry = prev_answer[-1] 

303 multi_entries_lst.pop() 

304 stdscr.addstr(4, 1, custom_entry) 

305 previous_entry = False 

306 else: 

307 custom_entry = "" 

308 

309 if multi_entries_lst: 

310 stdscr.addstr(6, 0, "Your entries:", curses.color_pair(1)) 

311 entries_str = " - ".join(multi_entries_lst) 

312 stdscr.addstr(7, 1, entries_str) 

313 

314 while True: 

315 key = stdscr.getch() 

316 if key == ord("\n") or key == curses.KEY_RIGHT: 

317 stdscr.clear() 

318 break 

319 elif key == 27: # 27 is the ASCII code for the Escape key 

320 custom_entry = "" 

321 stdscr.clear() 

322 break 

323 elif key == curses.KEY_BACKSPACE or key == 127: 

324 custom_entry = custom_entry[:-1] 

325 elif key == curses.KEY_LEFT: 

326 return GoQBack() 

327 else: 

328 custom_entry += chr(key) 

329 

330 stdscr.clear() 

331 

332 # Display the progress bar 

333 tot_length = 0 

334 for index, section in enumerate(SECTIONS): 

335 centered_str = f"{section}" 

336 

337 if index == section_idx: 

338 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(4)) 

339 else: 

340 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(5)) 

341 

342 tot_length += len(centered_str) 

343 

344 stdscr.addstr( 

345 2, 

346 1, 

347 f"{description if description else 'Please provide your custom entry:'}\n", 

348 curses.color_pair(2), 

349 ) 

350 stdscr.addstr( 

351 3, 

352 1, 

353 "Press Enter to submit custom entry or navigate with Right/Left Arrow Key between questions.", 

354 ) 

355 stdscr.addstr(4, 1, custom_entry) 

356 # Show the already stored entries 

357 if multi_entries_lst: 

358 stdscr.addstr(6, 0, "Your entries:", curses.color_pair(1)) 

359 entries_str = " - ".join(multi_entries_lst) 

360 stdscr.addstr(7, 1, entries_str) 

361 stdscr.refresh() 

362 

363 if not custom_entry.strip(): 

364 stdscr.addstr(5, 0, "Please provide a custom entry.", curses.color_pair(3)) 

365 stdscr.refresh() 

366 curses.delay_output(1000) # Delay for 1 second 

367 continue 

368 

369 multi_entries_lst.append(custom_entry.strip()) 

370 

371 if multi_choice: 

372 while True: 

373 # Display the progress bar 

374 tot_length = 0 

375 for index, section in enumerate(SECTIONS): 

376 centered_str = f"{section}" 

377 

378 if index == section_idx: 

379 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(4)) 

380 else: 

381 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(5)) 

382 

383 tot_length += len(centered_str) 

384 

385 stdscr.addstr( 

386 5, 

387 0, 

388 "Do you want to provide another custom entry? (yes/no) Or (d)elete last entry.", 

389 ) 

390 stdscr.addstr(6, 0, "Your entries:", curses.color_pair(1)) 

391 entries_str = " - ".join(multi_entries_lst) 

392 stdscr.addstr(7, 1, entries_str) 

393 stdscr.refresh() 

394 

395 # Get the user input 

396 key = stdscr.getch() 

397 if key == ord("y"): 

398 stdscr.clear() 

399 break 

400 elif key == ord("n") or key == curses.KEY_RIGHT: 

401 if multi_entries_lst: 

402 stdscr.clear() 

403 return multi_entries_lst 

404 elif key == ord("d") or key == 27: 

405 if multi_entries_lst: 

406 multi_entries_lst.pop() 

407 stdscr.clear() 

408 continue 

409 elif key == curses.KEY_LEFT: 

410 return GoQBack() 

411 

412 elif not multi_choice and len(multi_entries_lst) == 1: 

413 stdscr.clear() 

414 return multi_entries_lst 

415 

416 

417def get_custom_entry( 

418 description: str = "", 

419 multi_choice: bool = False, 

420 section_idx: int = 0, 

421 prev_answer: Any = None, 

422) -> str | list[str] | GoQBack: 

423 """ 

424 Wrapper to get custom entry/ies from the user. 

425 

426 :param description: The description of the question. 

427 :type description: str 

428 :param multi_choice: If multiple entries are allowed. 

429 :type multi_choice: bool 

430 :param section_idx: The index of the section. 

431 :type section_idx: int 

432 :param prev_answer: The previous answer. 

433 :type prev_answer: Any 

434 :return: The custom entry/ies. 

435 :rtype: str | list[str] | GoQBack 

436 """ 

437 

438 multi_entries_lst = [] 

439 

440 cstm_answer = curses.wrapper( 

441 lambda stdscr: custom_entry_insertion( 

442 stdscr, 

443 description, 

444 multi_entries_lst, 

445 multi_choice, 

446 section_idx, 

447 prev_answer, 

448 ) 

449 ) 

450 

451 if isinstance(cstm_answer, GoQBack): 

452 return GoQBack() 

453 

454 if not multi_entries_lst: 

455 return get_custom_entry(description, multi_choice, section_idx, prev_answer) 

456 

457 return multi_entries_lst if multi_choice else multi_entries_lst[0] 

458 

459 

460def yes_or_no( 

461 stdscr: curses.window, 

462 description: str = "", 

463 section_idx: int = 0, 

464 prev_answer: Any = None, 

465) -> bool | GoQBack: 

466 """ 

467 Ask the user for a yes or no answer. 

468 

469 :param stdscr: The curses window. 

470 :type stdscr: curses.window 

471 :param description: The description of the question. 

472 :type description: str 

473 :param section_idx: The index of the section. 

474 :type section_idx: int 

475 :param prev_answer: The previous answer. 

476 :type prev_answer: Any 

477 :return: The binary answer. 

478 :rtype: bool | GoQBack 

479 """ 

480 

481 while True: 

482 stdscr.clear() 

483 

484 # Display the progress bar 

485 tot_length = 0 

486 for index, section in enumerate(SECTIONS): 

487 centered_str = f"{section}" 

488 

489 if index == section_idx: 

490 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(4)) 

491 else: 

492 stdscr.addstr(0, tot_length, centered_str, curses.color_pair(5)) 

493 

494 tot_length += len(centered_str) 

495 

496 stdscr.addstr(2, 1, f"{description}\n", curses.color_pair(2)) 

497 stdscr.addstr(3, 1, "Select (y)es or (n)o.") 

498 if prev_answer is not None: 

499 if prev_answer: 

500 stdscr.addstr(5, 0, "Previous answer: Yes", curses.color_pair(1)) 

501 else: 

502 stdscr.addstr(5, 0, "Previous answer: No", curses.color_pair(1)) 

503 

504 key = stdscr.getch() 

505 

506 if key == ord("y"): 

507 stdscr.clear() 

508 return True 

509 elif key == ord("n"): 

510 stdscr.clear() 

511 return False 

512 elif key == curses.KEY_LEFT: 

513 return GoQBack() 

514 elif key == curses.KEY_RIGHT: 

515 if prev_answer or prev_answer is False: 

516 stdscr.clear() 

517 return prev_answer 

518 

519 

520def get_yes_no( 

521 description: str = "", section_idx: int = 0, prev_answer: Any = None 

522) -> GoQBack | bool: 

523 """ 

524 Wrapper to get an answer for a yes or no question from the user. 

525 

526 :param description: The description of the question. 

527 :param section_idx: The index of the section. 

528 :param prev_answer: The previous answer. 

529 :return: The binary answer. 

530 """ 

531 

532 bin_answer = curses.wrapper( 

533 lambda stdscr: yes_or_no(stdscr, description, section_idx, prev_answer) 

534 ) 

535 

536 return bin_answer 

537 

538 

539def main(): 

540 """ 

541 The main function of the program including argument parsing. 

542 Entry point of the denofo-questionnaire-cli executable. 

543 """ 

544 parser = argparse.ArgumentParser( 

545 description=( 

546 "Guide through a set of questions to produce the matching de novo gene" 

547 " file format of your choice for your de novo genes to describe." 

548 ) 

549 ) 

550 parser.add_argument( 

551 "-o", 

552 "--output", 

553 type=str, 

554 required=True, 

555 help=( 

556 "The path and name of the output file. If no extension is provided, the" 

557 " default is *.dngf (de novo gene format, which is in JSON format)." 

558 ), 

559 ) 

560 args = parser.parse_args() 

561 

562 # Check the NCBI Taxonomy Database 

563 check_NCBI_taxDB() 

564 

565 warnings.filterwarnings("ignore") 

566 

567 CLI_INTERFACE_FUNCTS = { 

568 "get_enum_choice_conversion": get_enum_choice_conversion, 

569 "get_custom_entry": get_custom_entry, 

570 "get_yes_no": get_yes_no, 

571 "valid_input_for_pydmodel": valid_input_for_pydmodel, 

572 } 

573 

574 output = add_extension(Path(args.output), "dngf") 

575 

576 # Call the function for questionaire 

577 try: 

578 # Initialize the colors and hide the cursor 

579 curses.wrapper(lambda stdscr: (init_colors(stdscr), curses.curs_set(0))) 

580 de_novo_questionnaire = DeNovoQuestionnaire(CLI_INTERFACE_FUNCTS) 

581 gene_annotation = de_novo_questionnaire.deNovoGeneAnnotation 

582 

583 try: 

584 curses.endwin() # End the curses window 

585 except curses.error: 

586 pass 

587 

588 except curses.error as e: 

589 if str(e) == "addwstr() returned ERR": 

590 print( 

591 "The terminal window is too small to display the questions." 

592 " Please increase the size of the terminal window and try again." 

593 ) 

594 else: 

595 print(f"{e}") 

596 

597 try: 

598 curses.endwin() # End the curses window 

599 except curses.error: 

600 pass 

601 

602 sys.exit(1) 

603 

604 # Save model in the dngf (de novo gene format) format (JSON) 

605 convert_to_json(gene_annotation, output) 

606 

607 

608if __name__ == "__main__": 

609 main()