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
« 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
17def _show_message(stdscr: curses.window, message: str):
18 """
19 Show a message in the curses window.
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()
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.
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
75def init_colors(stdscr: curses.window):
76 """
77 Initialize the colors for the curses window.
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)
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.
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]
124 selected.extend(prev_answer)
126 while True:
127 stdscr.clear()
129 # Display the progress bar
130 tot_length = 0
131 for index, section in enumerate(SECTIONS):
132 centered_str = f" • {section} • "
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))
139 tot_length += len(centered_str)
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 )
149 # Display all the items
150 for index, item in enumerate(items):
151 if index == current_index:
152 stdscr.attron(curses.A_REVERSE)
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}")
161 if index == current_index:
162 stdscr.attroff(curses.A_REVERSE)
164 stdscr.refresh()
165 key = stdscr.getch()
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()
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.
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)
226 enum_answer = curses.wrapper(
227 lambda stdscr: enum_choice_selection(
228 stdscr, question, items, multi_choice, selected, section_idx, prev_answer
229 )
230 )
232 if isinstance(enum_answer, GoQBack):
233 return GoQBack()
235 return selected if multi_choice else selected[0]
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.
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
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)
272 while True:
273 stdscr.clear()
275 # Display the progress bar
276 tot_length = 0
277 for index, section in enumerate(SECTIONS):
278 centered_str = f" • {section} • "
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))
285 tot_length += len(centered_str)
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 )
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 = ""
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)
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)
330 stdscr.clear()
332 # Display the progress bar
333 tot_length = 0
334 for index, section in enumerate(SECTIONS):
335 centered_str = f" • {section} • "
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))
342 tot_length += len(centered_str)
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()
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
369 multi_entries_lst.append(custom_entry.strip())
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} • "
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))
383 tot_length += len(centered_str)
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()
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()
412 elif not multi_choice and len(multi_entries_lst) == 1:
413 stdscr.clear()
414 return multi_entries_lst
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.
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 """
438 multi_entries_lst = []
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 )
451 if isinstance(cstm_answer, GoQBack):
452 return GoQBack()
454 if not multi_entries_lst:
455 return get_custom_entry(description, multi_choice, section_idx, prev_answer)
457 return multi_entries_lst if multi_choice else multi_entries_lst[0]
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.
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 """
481 while True:
482 stdscr.clear()
484 # Display the progress bar
485 tot_length = 0
486 for index, section in enumerate(SECTIONS):
487 centered_str = f" • {section} • "
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))
494 tot_length += len(centered_str)
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))
504 key = stdscr.getch()
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
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.
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 """
532 bin_answer = curses.wrapper(
533 lambda stdscr: yes_or_no(stdscr, description, section_idx, prev_answer)
534 )
536 return bin_answer
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()
562 # Check the NCBI Taxonomy Database
563 check_NCBI_taxDB()
565 warnings.filterwarnings("ignore")
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 }
574 output = add_extension(Path(args.output), "dngf")
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
583 try:
584 curses.endwin() # End the curses window
585 except curses.error:
586 pass
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}")
597 try:
598 curses.endwin() # End the curses window
599 except curses.error:
600 pass
602 sys.exit(1)
604 # Save model in the dngf (de novo gene format) format (JSON)
605 convert_to_json(gene_annotation, output)
608if __name__ == "__main__":
609 main()