Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dev.ej/wizard navigation #561

Merged
merged 16 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions everyvoice/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import sys
from enum import Enum
from pathlib import Path
from textwrap import dedent
from typing import Any, List, Optional

import typer
Expand Down Expand Up @@ -304,10 +305,43 @@

""",
)
def new_project():
def new_project(
trace: bool = typer.Option(
False, help="Enable question tree trace mode.", hidden=True
),
debug_state: bool = typer.Option(
False, help="Enable wizard state debug/trace mode.", hidden=True
),
resume_from: Optional[Path] = typer.Option(
None,
"--resume-from",
"-r",
exists=True,
dir_okay=False,
file_okay=True,
help="Resume from previously saved progress.",
autocompletion=complete_path,
),
):
from everyvoice.wizard.main_tour import get_main_wizard_tour

get_main_wizard_tour().run()
rich_print(

Check warning on line 328 in everyvoice/cli.py

View check run for this annotation

Codecov / codecov/patch

everyvoice/cli.py#L328

Added line #L328 was not covered by tests
Panel(
dedent(
"""
Welcome to the EveryVoice Wizard. We will guide you through the process of setting up the configuration for a new EveryVoice project.

Navigation: as any point, you can hit Ctrl+C to: go back a step, view progress, save progress, or exit the wizard.

From saved progress, you can resume at any time by running the same command with the --resume-from option.
"""
).strip()
)
)

get_main_wizard_tour(trace=trace, debug_state=debug_state).run(

Check warning on line 342 in everyvoice/cli.py

View check run for this annotation

Codecov / codecov/patch

everyvoice/cli.py#L342

Added line #L342 was not covered by tests
resume_from=resume_from
)


# Add preprocess to root
Expand Down
2 changes: 1 addition & 1 deletion everyvoice/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"text": ("test_text", "test_utils"),
"preprocessing": ("test_preprocessing",),
"model": ("test_model",),
"cli": ("test_wizard", "test_cli"),
"cli": ("test_wizard", "test_cli", "test_wizard_helpers"),
"evaluation": ("test_evaluation",),
**SUBMODULE_SUITES,
}
Expand Down
12 changes: 6 additions & 6 deletions everyvoice/tests/data/metadata.psv
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
basename|raw_text|characters|speaker|language|clean_text|label
LJ050-0269|The essential terms of such memoranda might well be embodied in an Executive order.|The essential terms of such memoranda might well be embodied in an Executive order.|default|default|the essential terms of such memoranda might well be embodied in an executive order.|LJ_TEST
LJ050-0270|This Commission can recommend no procedures for the future protection of our Presidents which will guarantee security.|This Commission can recommend no procedures for the future protection of our Presidents which will guarantee security.|default|default|this commission can recommend no procedures for the future protection of our presidents which will guarantee security.|LJ_TEST
LJ050-0271|The demands on the President in the execution of His responsibilities in today's world are so varied and complex|The demands on the President in the execution of His responsibilities in today's world are so varied and complex|default|default|the demands on the president in the execution of his responsibilities in today's world are so varied and complex|LJ_TEST
LJ050-0272.wav|and the traditions of the office in a democracy such as ours are so deep-seated as to preclude absolute security.|and the traditions of the office in a democracy such as ours are so deep-seated as to preclude absolute security.|default|default|and the traditions of the office in a democracy such as ours are so deep-seated as to preclude absolute security.|LJ_TEST
LJ050-0273|The Commission has, however, from its examination of the facts of President Kennedy's assassination|The Commission has, however, from its examination of the facts of President Kennedy's assassination|default|default|the commission has, however, from its examination of the facts of president kennedy's assassination|LJ_TEST
basename|raw_text|characters|speaker|language|clean_text|label|real_lang
LJ050-0269|The essential terms of such memoranda might well be embodied in an Executive order.|The essential terms of such memoranda might well be embodied in an Executive order.|default|default|the essential terms of such memoranda might well be embodied in an executive order.|LJ_TEST|eng
LJ050-0270|This Commission can recommend no procedures for the future protection of our Presidents which will guarantee security.|This Commission can recommend no procedures for the future protection of our Presidents which will guarantee security.|default|default|this commission can recommend no procedures for the future protection of our presidents which will guarantee security.|LJ_TEST|eng
LJ050-0271|The demands on the President in the execution of His responsibilities in today's world are so varied and complex|The demands on the President in the execution of His responsibilities in today's world are so varied and complex|default|default|the demands on the president in the execution of his responsibilities in today's world are so varied and complex|LJ_TEST|eng
LJ050-0272.wav|and the traditions of the office in a democracy such as ours are so deep-seated as to preclude absolute security.|and the traditions of the office in a democracy such as ours are so deep-seated as to preclude absolute security.|default|default|and the traditions of the office in a democracy such as ours are so deep-seated as to preclude absolute security.|LJ_TEST|eng
LJ050-0273|The Commission has, however, from its examination of the facts of President Kennedy's assassination|The Commission has, however, from its examination of the facts of President Kennedy's assassination|default|default|the commission has, however, from its examination of the facts of president kennedy's assassination|LJ_TEST|eng
15 changes: 12 additions & 3 deletions everyvoice/tests/preprocessed_audio_fixture.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import shutil
import tempfile
from pathlib import Path
from string import ascii_lowercase
Expand Down Expand Up @@ -72,8 +73,16 @@ def setUpClass(cls):
to_process=("audio", "energy", "pitch", "text", "spec"),
)
PreprocessedAudioFixture.lj_preprocessed.mkdir(parents=True, exist_ok=True)
(PreprocessedAudioFixture.lj_preprocessed / "duration").symlink_to(
PreprocessedAudioFixture.data_dir / "lj" / "preprocessed" / "duration",
)
try:
(PreprocessedAudioFixture.lj_preprocessed / "duration").symlink_to(
PreprocessedAudioFixture.data_dir / "lj/preprocessed/duration",
)
except OSError: # pragma: no cover
# Windows work-around
shutil.copytree(
PreprocessedAudioFixture.data_dir / "lj/preprocessed/duration",
PreprocessedAudioFixture.lj_preprocessed / "duration",
dirs_exist_ok=True,
)

PreprocessedAudioFixture._preprocess_ran = True
170 changes: 111 additions & 59 deletions everyvoice/tests/stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,63 @@

from loguru import logger

from everyvoice import wizard
from everyvoice.wizard import basic, dataset, prompts

_NOTSET = object() # Sentinel object for monkeypatch()


@contextmanager
def monkeypatch(obj, name, value) -> Generator:
class monkeypatch:
"""Monkey patch obj.name to value for the duration of the context manager's life.

Yields:
value: the value monkey-patched, for use with "as v" notation"""
saved_value = getattr(obj, name, _NOTSET)
setattr(obj, name, value)
try:
yield value
finally:
if saved_value is _NOTSET: # pragma: no cover
delattr(obj, name)
else:
setattr(obj, name, saved_value)

_NOTSET = object() # Sentinel object for monkeypatch()

QUIET = logging.CRITICAL + 100 # level high enough to disable all logging
def __init__(self, obj, name, value):
self.obj = obj
self.name = name
self.value = value

def __enter__(self):
self.saved_value = getattr(self.obj, self.name, self._NOTSET)
setattr(self.obj, self.name, self.value)
return self.value

@contextmanager
def patch_logger(
module, level: int = logging.INFO
) -> Generator[logging.Logger, None, None]:
def __exit__(self, *_exc_info):
if self.saved_value is self._NOTSET: # pragma: no cover
delattr(self.obj, self.name)
else:
setattr(self.obj, self.name, self.saved_value)


class patch_logger:
"""Monkey patch the logger for a given module with a unit testing logger
of the given level. Use level=QUIET to just silence logging.
of the given level.

Yields:
logger (logging.Logger): patched logger, e.g., for use in self.assertLogs(logger)
"""
with monkeypatch(module, "logger", logging.getLogger("UnitTesting")) as logger:
logger.setLevel(level)
yield logger

def __init__(self, module, level: int = logging.INFO):
self.monkey = monkeypatch(module, "logger", logging.getLogger("UnitTesting"))
self.level = level

def __enter__(self):
logger = self.monkey.__enter__()
logger.setLevel(self.level)
return logger

def __exit__(self, *_exc_info):
self.monkey.__exit__(*_exc_info)


@contextmanager
def mute_logger(module: str) -> Generator[None, None, None]:
"""
Temporarily mutes a module's `logger`.
"""Temporarily mutes a module's `logger`.

with mute_logger("everyvoice.base_cli.helpers"):
config = FastSpeech2Config()
Usage:
with mute_logger("everyvoice.base_cli.helpers"):
config = FastSpeech2Config()
"""
logger.disable(module)
try:
Expand All @@ -77,8 +87,7 @@ def capture_logs():
logger.remove(handler_id)


@contextmanager
def capture_stdout() -> Generator[io.StringIO, None, None]:
class capture_stdout:
"""Context manager to capture what is printed to stdout.

Usage:
Expand All @@ -92,9 +101,13 @@ def capture_stdout() -> Generator[io.StringIO, None, None]:
Yields:
stdout (io.StringIO): captured stdout
"""
f = io.StringIO()
with redirect_stdout(f):
yield f

def __enter__(self):
self.monkey = redirect_stdout(io.StringIO())
return self.monkey.__enter__()

def __exit__(self, *_exc_info):
self.monkey.__exit__(*_exc_info)


@contextmanager
Expand Down Expand Up @@ -132,11 +145,7 @@ def temp_chdir(path: Path) -> Generator[None, None, None]:
os.chdir(cwd)


@contextmanager
def patch_menu_prompt(
response_index: Union[int, list],
multi=False,
) -> Generator[io.StringIO, None, None]:
class patch_menu_prompt:
"""Context manager to simulate what option(s) the user selects in a simple_term_menu.

Args:
Expand All @@ -149,26 +158,52 @@ def patch_menu_prompt(
Yields:
stdout (io.StringIO): captured stdout stream.
"""
with capture_stdout() as stdout:
with monkeypatch(
prompts, "simple_term_menu", SimpleTermMenuStub(response_index, multi)
):
yield stdout

def __init__(self, response_index: Union[int, list], multi=False):
self.response_index = response_index
self.multi = multi

@contextmanager
def patch_input(response: Any, multi=False) -> Generator[None, None, None]:
def __enter__(self):
self.monkey1 = monkeypatch(
prompts,
"simple_term_menu",
SimpleTermMenuStub(self.response_index, self.multi),
)
self.monkey2 = capture_stdout()

self.monkey1.__enter__()
return self.monkey2.__enter__()

def __exit__(self, *_exc_info):
self.monkey2.__exit__(*_exc_info)
self.monkey1.__exit__(*_exc_info)


class patch_input:
"""Shortcut for patching the builtin input() function, which we need often.

Args: see class Say"""
with monkeypatch(builtins, "input", Say(response, multi)):
yield

def __init__(self, response: Any, multi=False):
self.response = response
self.multi = multi

@contextmanager
def null_patch() -> Generator[None, None, None]:
def __enter__(self):
self.monkey = monkeypatch(builtins, "input", Say(self.response, self.multi))
return self.monkey.__enter__()

def __exit__(self, *_exc_info):
self.monkey.__exit__(*_exc_info)


class null_patch:
"""dummy context manager when we must pass a monkeypatch but have nothing to patch"""
yield

def __enter__(self):
return None

def __exit__(self, *_exc_info):
pass


class Say:
Expand Down Expand Up @@ -226,14 +261,16 @@ def show(self):
class QuestionaryStub:
"""Stub class for the questionary module"""

def __init__(self, responses: Path | str | Sequence) -> None:
def __init__(self, responses: Path | str | Sequence, ask_ok: bool = False) -> None:
"""Constructor

Args:
responses: the (sequence of) answers the user is simulated to provide
ask_ok: if True, allow calling .ask(), which is an error otherwise
"""
self.last_index = -1
self.responses: Sequence
self.ask_ok = ask_ok
if isinstance(responses, (Path, str)):
self.responses = [responses]
else:
Expand All @@ -245,10 +282,13 @@ def path(self, *args, **kwargs):
text = path

def ask(self): # pragma: no cover
# This will trigger a unit test failure if we use .ask()
raise Exception(
"Always use unsafe_ask() for questionary instances so that KeyboardInterrupt gets passed up to us."
)
if self.ask_ok:
return self.unsafe_ask()
else:
# This will trigger a unit test failure if we use .ask()
raise Exception(
"Always use unsafe_ask() for questionary instances so that KeyboardInterrupt gets passed up to us."
)

def unsafe_ask(self):
self.last_index += 1
Expand All @@ -260,12 +300,24 @@ def unsafe_ask(self):
return response


@contextmanager
def patch_questionary(responses: Path | str | Sequence) -> Generator[None, None, None]:
class patch_questionary:
"""Shortcut for monkey patching questionary everywhere

Args: See QuestionaryStub"""
stub = QuestionaryStub(responses)
module_name = "questionary"
with monkeypatch(basic, module_name, stub), monkeypatch(dataset, module_name, stub):
yield

def __init__(self, responses: Path | str | Sequence, ask_ok: bool = False):
self.responses = responses
self.ask_ok = ask_ok

def __enter__(self):
stub = QuestionaryStub(self.responses, self.ask_ok)
patch_name = "questionary"
self.monkeys = [
monkeypatch(module_to_patch, patch_name, stub)
for module_to_patch in [wizard, basic, dataset]
]
return [monkey.__enter__() for monkey in self.monkeys]

def __exit__(self, *_exc_info):
for monkey in self.monkeys:
monkey.__exit__(*_exc_info)
9 changes: 5 additions & 4 deletions everyvoice/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,14 @@ def test_update_schema(self):
# i.e., that we didn't change the models but forget to update the schemas.
for filename in SCHEMAS_TO_OUTPUT:
with open(Path(tmpdir) / filename, encoding="utf8") as f:
new_schema = f.read()
new_schema = f.read().replace("\\\\", "/") # force paths to posix
try:
with open(EV_DIR / ".schema" / filename, encoding="utf8") as f:
saved_schema = f.read()
except FileNotFoundError:
except FileNotFoundError as e:
raise AssertionError(
f'Schema file {filename} is missing, please run "everyvoice update-schemas".'
)
) from e
self.assertEqual(
saved_schema,
new_schema,
Expand Down Expand Up @@ -296,6 +296,7 @@ def test_expensive_imports_are_tucked_away(self):
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
env=dict(os.environ, PYTHONPROFILEIMPORTTIME="1"),
check=True,
)

msg = '\n\nPlease avoid causing {} being imported from "everyvoice -h".\nIt is a relatively expensive import and slows down shell completion.\nRun "PYTHONPROFILEIMPORTTIME=1 everyvoice -h" and inspect the logs to see why it\'s being imported.'
Expand All @@ -305,7 +306,7 @@ def test_expensive_imports_are_tucked_away(self):

class TestBaseCLIHelper(TestCase):
def test_save_configuration_to_log_dir(self):
with TemporaryDirectory() as tempdir, mute_logger(
with TemporaryDirectory(ignore_cleanup_errors=True) as tempdir, mute_logger(
"everyvoice.base_cli.helpers"
):
tempdir = Path(tempdir)
Expand Down
Loading