Skip to content

Commit

Permalink
feat: add --skip-answered flag to avoid repeating recorded answers
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
ljossha and yajo authored Sep 3, 2023
1 parent 1936824 commit 8619fc8
Show file tree
Hide file tree
Showing 4 changed files with 187 additions and 4 deletions.
6 changes: 6 additions & 0 deletions copier/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down
12 changes: 8 additions & 4 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
172 changes: 172 additions & 0 deletions tests/test_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 8619fc8

Please sign in to comment.