From cf4f10ff1eb996bb242d96f7afd86c9b7b20b5c4 Mon Sep 17 00:00:00 2001 From: Eric Joanis Date: Thu, 19 Sep 2024 09:53:09 -0400 Subject: [PATCH] feat: wizard with Ctrl-C menu to go back, and more --- everyvoice/tests/test_wizard.py | 47 ++++++++++++++++++++++++++ everyvoice/wizard/__init__.py | 59 +++++++++++++++++++++++++++++++++ everyvoice/wizard/basic.py | 12 +++++++ 3 files changed, 118 insertions(+) diff --git a/everyvoice/tests/test_wizard.py b/everyvoice/tests/test_wizard.py index 2ca6d084..c76bab7b 100644 --- a/everyvoice/tests/test_wizard.py +++ b/everyvoice/tests/test_wizard.py @@ -1731,6 +1731,53 @@ def test_multilingual_multispeaker_false_config(self): self.assertIn("multilingual: false", text_to_spec_config) self.assertIn("multispeaker: false", text_to_spec_config) + def test_control_c(self): + # Three Ctrl-C exits + tour = make_trivial_tour() + with patch_input(KeyboardInterrupt()), patch_menu_prompt(KeyboardInterrupt()): + with self.assertRaises(SystemExit): + tour.run() + + # Ctrl-C plus option 4 (Exit) exits + tour = make_trivial_tour() + with patch_input(KeyboardInterrupt()): + with patch_menu_prompt(4): # 4 is "Exit" in keyboard interrupt handling + with self.assertRaises(SystemExit): + tour.run() + + resulting_state = { + SN.name_step.value: "project_name", + SN.contact_name_step.value: "user name", + SN.contact_email_step.value: "email@mail.com", + } + + tour = make_trivial_tour() + with patch_input( + ["project_name", KeyboardInterrupt(), "user name", "email@mail.com"], + multi=True, + ): + # Ctrl-C once, then hit 1 to continue + with patch_menu_prompt([KeyboardInterrupt(), 1], multi=True): + tour.run() + self.assertEqual(tour.state, resulting_state) + + tour = make_trivial_tour() + with patch_input( + [ + "bad_name", + KeyboardInterrupt(), + "project_name", + "bad user name", + KeyboardInterrupt(), + "user name", + "email@mail.com", + ], + multi=True, + ): + with patch_menu_prompt(0): # say 0==go back each time + tour.run() + self.assertEqual(tour.state, resulting_state) + def test_trace(self): tour = make_trivial_tour(trace=True) tour.trace = True diff --git a/everyvoice/wizard/__init__.py b/everyvoice/wizard/__init__.py index 26786299..4d4f96c2 100644 --- a/everyvoice/wizard/__init__.py +++ b/everyvoice/wizard/__init__.py @@ -9,6 +9,7 @@ from rich import print as rich_print from rich.panel import Panel +from .prompts import get_response_from_menu_prompt from .utils import EnumDict as State from .utils import NodeMixinWithNavigation @@ -122,6 +123,23 @@ def run(self): sys.exit(1) self.run() + def is_reversible(self): + """Return True if the step's effects can be reversed, False otherwise. + + Also implement undo if you return True and undoing the step requires state changes. + """ + return False + + def undo(self): + """Undo the effects of the step. + + If you implement undo, also implement is_reversible with return True + """ + self.response = None + self.completed = False + if self.state is not None: + del self.state[self.name] + class RootStep(Step): """Dummy step sitting at the root of the tour""" @@ -186,6 +204,47 @@ def add_step(self, step: Step, parent: Step): children.insert(0, step) parent.children = children + def keyboard_interrupt_action(self, node): + """Handle a keyboard interrupt by asking the user what to do""" + action = None + # Three Ctrl-C in a row, we just exist, but two in a row might be an + # accident so ask again. + for _ in range(2): + try: + action = get_response_from_menu_prompt( + "What would you like to do?", + [ + "Go back one step", + "Continue", + "View the question tree", + "Save progress", + "Exit", + ], + return_indices=True, + ) + break + except KeyboardInterrupt: + continue + if action == 0: + prev = node.prev() + if prev.is_reversible(): + prev.undo() + return prev + else: + rich_print( + f"Sorry, the effects of the {prev.name} cannot be undone, continuing. If you need to go back, you'll have to restart the wizard." + ) + return node + elif action == 1: + return node + elif action == 2: + self.visualize(highlight=node) + return node + elif action == 3: + return node + else: # still None, or the highest value in the choice list. + sys.exit(1) + def run(self): """Run the tour by traversing the tree depth-first""" node = self.root diff --git a/everyvoice/wizard/basic.py b/everyvoice/wizard/basic.py index 9504a96f..3b1eebcd 100644 --- a/everyvoice/wizard/basic.py +++ b/everyvoice/wizard/basic.py @@ -66,6 +66,9 @@ def effect(self): f"Great! Launching Configuration Wizard 🧙 for project named '{self.response}'." ) + def is_reversible(self): + return True + class ContactNameStep(Step): DEFAULT_NAME = StepNames.contact_name_step @@ -84,6 +87,9 @@ def validate(self, response): def effect(self): rich_print(f"Great! Nice to meet you, '{self.response}'.") + def is_reversible(self): + return True + class ContactEmailStep(Step): DEFAULT_NAME = StepNames.contact_email_step @@ -125,6 +131,9 @@ def effect(self): f"Great! Your contact email '{self.response}' will be saved to your models." ) + def is_reversible(self): + return True + class OutputPathStep(Step): DEFAULT_NAME = StepNames.output_step @@ -189,6 +198,9 @@ def effect(self): f"The Configuration Wizard 🧙 will put your files here: '{self.output_path}'" ) + def is_reversible(self): + return True + class ConfigFormatStep(Step): DEFAULT_NAME = StepNames.config_format_step