From 8619fc8a68d153582aacf2a59a5e73a554f119e0 Mon Sep 17 00:00:00 2001 From: Oleksii Siechko Date: Mon, 4 Sep 2023 00:15:49 +0300 Subject: [PATCH] feat: add `--skip-answered` flag to avoid repeating recorded answers This change addresses a usability issue troubling users when they run 'copier update'. Previously, each time 'copier update' was run, the user had to review all responses given in the answers file. This was a repetitive and laborious process, particularly for users dealing with extensive templates. This PR introduces a new flag --skip-answered to improve the user experience and reduce friction during updates. The new flag allows users to bypass the review of questions already answered in the past. It automatically pulls responses from the answers file and presents only the newly added template questions to the user for their review. `skip-answered` will skip all answered questions and ask user input only for new inputs if they are present. Fixes #1178 Co-authored-by: Jairo Llopis <973709+yajo@users.noreply.github.com> --- copier/cli.py | 6 ++ copier/main.py | 12 ++- tests/test_cli.py | 1 + tests/test_prompt.py | 172 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 4 deletions(-) diff --git a/copier/cli.py b/copier/cli.py index 46f032f87..1e9a8ba10 100644 --- a/copier/cli.py +++ b/copier/cli.py @@ -349,6 +349,11 @@ class CopierUpdateSubApp(_Subcommand): ["-l", "-f", "--defaults"], help="Use default answers to questions, which might be null if not specified.", ) + skip_answered = cli.Flag( + ["--skip-answered"], + default=False, + help="Skip questions that have already been answered", + ) @handle_exceptions def main(self, destination_path: cli.ExistingDirectory = ".") -> int: @@ -367,6 +372,7 @@ def main(self, destination_path: cli.ExistingDirectory = ".") -> int: conflict=self.conflict, context_lines=self.context_lines, defaults=self.defaults, + skip_answered=self.skip_answered, overwrite=True, ) as worker: worker.run_update() diff --git a/copier/main.py b/copier/main.py index bea80fc9b..bd664422a 100644 --- a/copier/main.py +++ b/copier/main.py @@ -186,6 +186,7 @@ class Worker: conflict: Literal["inline", "rej"] = "inline" context_lines: PositiveInt = 3 unsafe: bool = False + skip_answered: bool = False answers: AnswersMap = field(default_factory=AnswersMap, init=False) _cleanup_hooks: List[Callable] = field(default_factory=list, init=False) @@ -420,6 +421,9 @@ def _ask(self) -> None: var_name=var_name, **details, ) + if self.skip_answered and var_name in result.last: + # Skip a question when the user already answered it. + continue # Skip a question when the skip condition is met. if not question.get_when(): # Omit its answer from the answers file. @@ -861,11 +865,11 @@ def _apply_update(self): self._execute_tasks( self.template.migration_tasks("before", self.subproject.template) ) - # Clear last answers cache to load possible answers migration - with suppress(AttributeError): + # Clear last answers cache to load possible answers migration, if skip_answered flag is not set + if self.skip_answered is False: self.answers = AnswersMap() - with suppress(AttributeError): - del self.subproject.last_answers + with suppress(AttributeError): + del self.subproject.last_answers # Do a normal update in final destination with replace( self, diff --git a/tests/test_cli.py b/tests/test_cli.py index 078806d19..a849748ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -214,6 +214,7 @@ def test_update_help() -> None: _help = COPIER_CMD("update", "--help") assert "-o, --conflict" in _help assert "copier update [SWITCHES] [destination_path=.]" in _help + assert "--skip-answered" in _help def test_python_run() -> None: diff --git a/tests/test_prompt.py b/tests/test_prompt.py index b94d5e00f..4cccaaec8 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -45,6 +45,35 @@ "[[ _copier_conf.answers_file ]].tmpl": "[[_copier_answers|to_nice_yaml]]", } +MARIO_TREE_WITH_NEW_FIELD: Mapping[StrOrPath, Union[str, bytes]] = { + "copier.yml": ( + f"""\ + _templates_suffix: {SUFFIX_TMPL} + _envops: {BRACKET_ENVOPS_JSON} + in_love: + type: bool + default: yes + your_name: + type: str + default: Mario + help: If you have a name, tell me now. + your_enemy: + type: str + default: Bowser + secret: yes + help: Secret enemy name + your_sister: + type: str + default: Luigi + help: Your sister's name + what_enemy_does: + type: str + default: "[[ your_enemy ]] hates [[ your_name ]]" + """ + ), + "[[ _copier_conf.answers_file ]].tmpl": "[[_copier_answers|to_nice_yaml]]", +} + @pytest.mark.parametrize( "name, args", @@ -117,6 +146,149 @@ def test_copy_default_advertised( assert "_commit: v2" in Path(".copier-answers.yml").read_text() +@pytest.mark.parametrize( + "name, args", + [ + ("Mario", ()), # Default name in the template + ("Luigi", ("--data=your_name=Luigi",)), + ("None", ("--data=your_name=None",)), + ], +) +def test_update_skip_answered( + tmp_path_factory: pytest.TempPathFactory, + spawn: Spawn, + name: str, + args: Tuple[str, ...], +) -> None: + """Test that the questions for the user are OK""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + with local.cwd(src): + build_file_tree(MARIO_TREE) + git("init") + git("add", ".") + git("commit", "-m", "v1") + git("tag", "v1") + git("commit", "--allow-empty", "-m", "v2") + git("tag", "v2") + with local.cwd(dst): + # Copy the v1 template + tui = spawn( + COPIER_PATH + ("copy", str(src), ".", "--vcs-ref=v1") + args, timeout=10 + ) + # Check what was captured + expect_prompt(tui, "in_love", "bool") + tui.expect_exact("(Y/n)") + tui.sendline() + tui.expect_exact("Yes") + if not args: + expect_prompt( + tui, "your_name", "str", help="If you have a name, tell me now." + ) + tui.expect_exact(name) + tui.sendline() + expect_prompt(tui, "your_enemy", "str", help="Secret enemy name") + tui.expect_exact("******") + tui.sendline() + expect_prompt(tui, "what_enemy_does", "str") + tui.expect_exact(f"Bowser hates {name}") + tui.sendline() + tui.expect_exact(pexpect.EOF) + assert "_commit: v1" in Path(".copier-answers.yml").read_text() + # Update subproject + git("init") + git("add", ".") + assert "_commit: v1" in Path(".copier-answers.yml").read_text() + git("commit", "-m", "v1") + tui = spawn( + COPIER_PATH + + ( + "update", + "--skip-answered", + ), + timeout=30, + ) + # Check what was captured + expect_prompt(tui, "your_enemy", "str", help="Secret enemy name") + tui.expect_exact("******") + tui.sendline() + tui.expect_exact(pexpect.EOF) + assert "_commit: v2" in Path(".copier-answers.yml").read_text() + + +@pytest.mark.parametrize( + "name, args", + [ + ("Mario", ()), # Default name in the template + ("Luigi", ("--data=your_name=Luigi",)), + ("None", ("--data=your_name=None",)), + ], +) +def test_update_with_new_field_in_new_version_skip_answered( + tmp_path_factory: pytest.TempPathFactory, + spawn: Spawn, + name: str, + args: Tuple[str, ...], +) -> None: + """Test that the questions for the user are OK""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + with local.cwd(src): + build_file_tree(MARIO_TREE) + git("init") + git("add", ".") + git("commit", "-m", "v1") + git("tag", "v1") + build_file_tree(MARIO_TREE_WITH_NEW_FIELD) + git("add", ".") + git("commit", "-m", "v2") + git("tag", "v2") + with local.cwd(dst): + # Copy the v1 template + tui = spawn( + COPIER_PATH + ("copy", str(src), ".", "--vcs-ref=v1") + args, timeout=10 + ) + # Check what was captured + expect_prompt(tui, "in_love", "bool") + tui.expect_exact("(Y/n)") + tui.sendline() + tui.expect_exact("Yes") + if not args: + expect_prompt( + tui, "your_name", "str", help="If you have a name, tell me now." + ) + tui.expect_exact(name) + tui.sendline() + expect_prompt(tui, "your_enemy", "str", help="Secret enemy name") + tui.expect_exact("******") + tui.sendline() + expect_prompt(tui, "what_enemy_does", "str") + tui.expect_exact(f"Bowser hates {name}") + tui.sendline() + tui.expect_exact(pexpect.EOF) + assert "_commit: v1" in Path(".copier-answers.yml").read_text() + # Update subproject + git("init") + git("add", ".") + assert "_commit: v1" in Path(".copier-answers.yml").read_text() + git("commit", "-m", "v1") + tui = spawn( + COPIER_PATH + + ( + "update", + "--skip-answered", + ), + timeout=30, + ) + # Check what was captured + expect_prompt(tui, "your_enemy", "str", help="Secret enemy name") + tui.expect_exact("******") + tui.sendline() + expect_prompt(tui, "your_sister", "str", help="Your sister's name") + tui.expect_exact("Luigi") + tui.sendline() + tui.expect_exact(pexpect.EOF) + assert "_commit: v2" in Path(".copier-answers.yml").read_text() + + @pytest.mark.parametrize( "question_1", # All these values evaluate to true