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