Skip to content

Commit

Permalink
feat: wizard can now save progress and resume from saved progress
Browse files Browse the repository at this point in the history
  • Loading branch information
joanise committed Sep 23, 2024
1 parent cf4f10f commit a7356bb
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 14 deletions.
12 changes: 11 additions & 1 deletion everyvoice/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,20 @@ class ModelTypes(str, Enum):
)
def new_project(
trace: bool = typer.Option(False, help="Enable trace/debugging mode", hidden=True),
resume_from: Optional[Path] = typer.Option(
None,
"--resume-from",
"-r",
exists=True,
dir_okay=False,
file_okay=True,
help="Saved Q&A list to resume from",
autocompletion=complete_path,
),
):
from everyvoice.wizard.main_tour import get_main_wizard_tour

get_main_wizard_tour(trace=trace).run()
get_main_wizard_tour(trace=trace).run(resume_from=resume_from)


# Add preprocess to root
Expand Down
115 changes: 109 additions & 6 deletions everyvoice/wizard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
import os
import sys
from enum import Enum
from pathlib import Path
from typing import Optional, Sequence

from anytree import RenderTree
import yaml
from anytree import PreOrderIter, RenderTree
from rich import print as rich_print
from rich.panel import Panel

from everyvoice._version import VERSION

from .prompts import get_response_from_menu_prompt
from .utils import EnumDict as State
from .utils import NodeMixinWithNavigation
Expand Down Expand Up @@ -98,11 +102,14 @@ def __init__(
def __repr__(self) -> str:
return f"{self.name}: {super().__repr__()}"

def run(self):
def run(self, saved_response=None):
"""Prompt the user and save the response to the response attribute.
If this method returns something truthy, continue, otherwise ask the prompt again.
"""
self.response = self.prompt()
if saved_response is not None:
self.response = saved_response
else:
self.response = self.prompt()
self.response = self.sanitize_input(self.response)
if self.tour is not None and self.tour.trace:
rich_print(f"{self.name}: '{self.response}'")
Expand Down Expand Up @@ -146,9 +153,15 @@ class RootStep(Step):

DEFAULT_NAME = "Root"

def run(self):
def run(self, saved_response=None):
pass

def validate(self, response):
return response is None

def is_reversible(self):
return True


class Tour:
def __init__(
Expand Down Expand Up @@ -241,13 +254,78 @@ def keyboard_interrupt_action(self, node):
self.visualize(highlight=node)
return node
elif action == 3:
self.save_progress(node)
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"""
def resume(self, resume_from: Path) -> Optional[Step]:
"""Resume the tour from a file containing the saved progress
Returns: the node to continue from after applying the saved history."""
with open(resume_from, "r", encoding="utf8") as f:
q_and_a_list = yaml.safe_load(f)
node = self.root
q_and_a_iter = iter(q_and_a_list)
q_and_a = next(q_and_a_iter, ["", ""])
software, version = q_and_a
if software != "EveryVoice Wizard" or version != VERSION:
rich_print(
f"[yellow]Warning: saved progress file is for {software} version '{version}', but this is version '{VERSION}'. Proceeding anyway, but be aware that the saved responses may not be compatible.[/yellow]"
)
q_and_a = next(q_and_a_iter, None)
while node is not None and q_and_a is not None:
try:
saved_node_name, saved_response = q_and_a
except ValueError:
rich_print(
f"Error: saved question and response '{q_and_a}' is invalid. This does not look like a valid resume-from file. Aborting."
)
sys.exit(1)
if saved_node_name.lower() != node.name.lower():
rich_print(
f"Error: next tour question is {node.name} but resume list has {saved_node_name} instead.\n"
"Your resume-from file is likely out of sync. Aborting."
)
sys.exit(1)
if node.validate(saved_response):
if node.name != "Root":
rich_print(
f"Applying saved response '{saved_response}' for [green]{node.name}[/green]"
)
node.run(saved_response=saved_response)
else:
rich_print(
f"Error: saved response '{saved_response}' for {node.name} is invalid. The remaining saved responses will not be applied, but you can continue from here."
)
return node

node = node.next()
q_and_a = next(q_and_a_iter, None)

if q_and_a is not None:
assert node is None
rich_print(
"Error: saved responses left to apply but no more questions in the tour. Aborting."
)
sys.exit(1)

if node is not None:
assert q_and_a is None
rich_print(
Panel(
"All saved responses were applied successfully, resuming where you left off."
)
)

return node

def run(self, resume_from: Optional[Path] = None):
"""Run the tour by traversing the tree depth-first"""
if resume_from is not None:
node = self.resume(resume_from)
else:
node = self.root
while node is not None:
if self.trace:
self.visualize(node)
Expand Down Expand Up @@ -288,6 +366,31 @@ def display(pre: str, name: str) -> str:
text += treestr + "\n"
rich_print(Panel(text.rstrip()))

def get_progress(self, current_node: Step):
"""Return a list of questions and answers for the tour"""
q_and_a_list = [[node.name, node.response] for node in PreOrderIter(self.root)]
current_node_index = q_and_a_list.index(
[current_node.name, current_node.response]
)
return q_and_a_list[:current_node_index]

def save_progress(self, current_node: Step):
"""Save the questions and answers of the tour to a file for future resuming"""
filename = input("Enter the filename to save the tree to: ")
try:
with open(filename, "w", encoding="utf8") as f:
yaml.dump(
[["EveryVoice Wizard", VERSION]] + self.get_progress(current_node),
f,
allow_unicode=True,
)
rich_print("Saved progress to", filename)

with open(filename, "r", encoding="utf8") as f:
rich_print(yaml.safe_load(f))
except OSError as e:
rich_print(f"Error saving progress to {filename}: {e}")


class StepNames(Enum):
name_step = "Name Step"
Expand Down
12 changes: 6 additions & 6 deletions everyvoice/wizard/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

import sys
from typing import Iterable, Sequence
from typing import Sequence

import rich
from questionary import Style
Expand Down Expand Up @@ -40,7 +40,7 @@ def get_response_from_menu_prompt(
multi=False,
search=False,
return_indices=False,
) -> str | int | Iterable[str] | Iterable[int]:
) -> str | int | list[str] | list[int]:
"""Given some prompt text and a list of choices, create a simple terminal window
and return the index of the choice
Expand All @@ -57,8 +57,8 @@ def get_response_from_menu_prompt(
----- | -------------- | -------
false | false | str: choice selected
false | true | int: index of choice selected
true | false | Iterable[str]: choices selected
true | true | Iterable[int]: indices of choices selected
true | false | list[str]: choices selected
true | true | list[int]: indices of choices selected
"""
if prompt_text:
rich.print(Panel(prompt_text))
Expand All @@ -80,9 +80,9 @@ def get_response_from_menu_prompt(
sys.stdout.write("\033[K")
if multi:
if selection is None:
return ()
return []
elif return_indices:
return selection
return list(selection) # selection might be a tuple, but we need a list
else:
return [choices[i] for i in selection]
else:
Expand Down
3 changes: 2 additions & 1 deletion everyvoice/wizard/simple_term_menu_win_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ def __init__(self, choices, multi_select, **_kwargs):
self.choices = choices
self.multi_select = multi_select

def show(self):
def show(self) -> int | list[int]:
"""Show the menu and return the index/indices of the selected choice(s)."""
if self.multi_select:
responses = questionary.checkbox(
message="",
Expand Down

0 comments on commit a7356bb

Please sign in to comment.