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

1 import curses

2 import argparse

3 import warnings

4 import sys

5 from pydantic import ValidationError, BaseModel

6 from enum import Enum

7 from typing import Any

8 from pathlib import Path

9 from denofo.utils.helpers import add_extension

10 from denofo.converter.convert import convert_to_json

11 from denofo.models import ModelValidError

12 from denofo.utils.constants import SECTIONS, GoQBack

13 from denofo.utils.ncbiTaxDBcheck import check_NCBI_taxDB

14 from denofo.questionnaire.questions import DeNovoQuestionnaire

15

16

17 def _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

37 def 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

75 def 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

90 def 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

200 def 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

238 def 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

417 def 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

460 def 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

520 def 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

539 def 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

608 if __name__ == "__main__":

609 main()