Repository: copier-org/copier (python)

Issue:
bug: `copier update` throws error if previously selected value is  disabled
### Describe the problem

When trying to update the project created from template, we ran into the scenario where previously selected value choice was disabled, causing an error.

We have a setup similar to this:
```
version:
  type: str
  help: Choose your Python version
  choices:
    - "3.8"
    - "3.9"

image:
  type: str
  help: Choose a base Docker image
  choices:
    slim (python==3.8):
      value: slim-3.8
      validator: '{% if version == "3.8" %}{% else %}does not apply{% endif %}'
    bullseye (python==3.8):
      value: bullseye-3.8
      validator: '{% if version == "3.8" %}{% else %}does not apply{% endif %}'
    alpine (python==3.9):
      value: alpine-3.9
      validator: '{% if version == "3.9" %}{% else %}does not apply{% endif %}'
    bullseye (python==3.9):
      value: bullseye-3.9
      validator: '{% if version == "3.9" %}{% else %}does not apply{% endif %}'
```

Basic idea is to show different docker images based on previous versions, and there might be scenario where some images only available only certain versions.

What would be best way to overcome this error? Is this a bug on `copier` side?

### Template

Minimal examples to reproduce the issue:
- template: https://github.com/MantasPetrikasVinted/copier-playground
- filled project: https://github.com/MantasPetrikasVinted/copier-playground-filled

### To Reproduce

1. Run `copier copy git@github.com:MantasPetrikasVinted/copier-playground.git`
2. Select `3.8` and `slim-3.8`
3. Run `copier update`
4. Select `3.9`
5. Get error message

or 

1. Run `git clone git@github.com:MantasPetrikasVinted/copier-playground-filled`
2. Run `copier update`
3. Select `3.9`
4. Get error message




### Logs

```console
copier-playground-filled git:(main) copier update                                                          
No git tags found in template; using HEAD as ref
Updating to template version 0.0.0.post3.dev0+1b21cd9
No git tags found in template; using HEAD as ref
🎤 Choose your Python version
   3.9
Traceback (most recent call last):
  File "/Users/mantas/.local/bin/copier", line 8, in <module>
    sys.exit(copier_app_run())
             ^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/plumbum/cli/application.py", line 638, in run
    inst, retcode = subapp.run(argv, exit=False)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/plumbum/cli/application.py", line 633, in run
    retcode = inst.main(*tailargs)
              ^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/decorator.py", line 232, in fun
    return caller(func, *(extras + args), **kw)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 71, in handle_exceptions
    return method(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 402, in main
    with self._worker(
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 205, in __exit__
    raise value
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 410, in main
    worker.run_update()
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 826, in run_update
    self._apply_update()
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 882, in _apply_update
    with replace(
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 205, in __exit__
    raise value
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 890, in _apply_update
    current_worker.run_copy()
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 745, in run_copy
    self._ask()
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 454, in _ask
    new_answer = unsafe_prompt(
                 ^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/questionary/prompt.py", line 221, in unsafe_prompt
    question = create_question_func(**_kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/questionary/prompts/select.py", line 146, in select
    ic = InquirerControl(
         ^^^^^^^^^^^^^^^^
  File "/Users/mantas/.local/pipx/venvs/copier/lib/python3.12/site-packages/questionary/prompts/common.py", line 274, in __init__
    raise ValueError(
ValueError: Invalid 'initial_choice' value ('<questionary.prompts.common.Choice object at 0x108887410>'). It must be a selectable value.
```


### Expected behavior

First selectable value is selected


### Operating system distribution and version

macOS Ventura 13.6

### Copier version

copier 9.1.1

### Python version

Python 3.9.17

### Installation method

pipx+pypi

Pull request:
fix(updating): unset invalid last answers
I've fixed a bug related to handling invalid last answers which caused Copier to raise an exception while updating a project. The solution is to unset a previously recorded answer if parsing or validation fails. For instance, a choice question with choice item validators will fall back to either its default value or the first enabled choice item. In interactive mode without the `--defaults` switch, the user will be given the opportunity to select a valid answer again.

Fixes #1483.

Patch:
diff --git a/copier/main.py b/copier/main.py
index 277f01d..0a4b95d 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -418,7 +418,7 @@ class Worker:
             return True
         return self._solve_render_conflict(dst_relpath)
 
-    def _ask(self) -> None:
+    def _ask(self) -> None:  # noqa: C901
         """Ask the questions of the questionnaire and record their answers."""
         result = AnswersMap(
             user_defaults=self.user_defaults,
@@ -434,8 +434,18 @@ class Worker:
                 var_name=var_name,
                 **details,
             )
+            # Delete last answer if it cannot be parsed or validated, so a new
+            # valid answer can be provided.
+            if var_name in result.last:
+                try:
+                    answer = question.parse_answer(result.last[var_name])
+                except Exception:
+                    del result.last[var_name]
+                else:
+                    if question.validate_answer(answer):
+                        del result.last[var_name]
+            # Skip a question when the user already answered it.
             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():


Test patch:
diff --git a/tests/test_templated_prompt.py b/tests/test_templated_prompt.py
index 2a1b124..bccf0d2 100644
--- a/tests/test_templated_prompt.py
+++ b/tests/test_templated_prompt.py
@@ -8,6 +8,8 @@ import pexpect
 import pytest
 import yaml
 from pexpect.popen_spawn import PopenSpawn
+from plumbum import local
+from plumbum.cmd import git
 
 from copier import Worker
 from copier.errors import InvalidTypeError
@@ -18,9 +20,11 @@ from .helpers import (
     BRACKET_ENVOPS_JSON,
     COPIER_PATH,
     SUFFIX_TMPL,
+    Keyboard,
     Spawn,
     build_file_tree,
     expect_prompt,
+    git_init,
 )
 
 main_default = "copier"
@@ -364,6 +368,84 @@ def test_templated_prompt_with_conditional_choices(
     tui.sendline()
 
 
+def test_templated_prompt_update_previous_answer_disabled(
+    tmp_path_factory: pytest.TempPathFactory, spawn: Spawn
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            (src / "copier.yml"): (
+                """\
+                cloud:
+                    type: str
+                    help: Which cloud provider do you use?
+                    choices:
+                        - Any
+                        - AWS
+                        - Azure
+                        - GCP
+                iac:
+                    type: str
+                    help: Which IaC tool do you use?
+                    choices:
+                        Terraform: tf
+                        Cloud Formation:
+                            value: cf
+                            validator: "{% if cloud != 'AWS' %}Requires AWS{% endif %}"
+                        Azure Resource Manager:
+                            value: arm
+                            validator: "{% if cloud != 'Azure' %}Requires Azure{% endif %}"
+                        Deployment Manager:
+                            value: dm
+                            validator: "{% if cloud != 'GCP' %}Requires GCP{% endif %}"
+                """
+            ),
+            (src / "{{ _copier_conf.answers_file }}.jinja"): (
+                "{{ _copier_answers|to_nice_yaml }}"
+            ),
+        }
+    )
+
+    with local.cwd(src):
+        git_init("v1")
+        git("tag", "v1")
+
+    tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)), timeout=10)
+    expect_prompt(tui, "cloud", "str", help="Which cloud provider do you use?")
+    tui.sendline(Keyboard.Down)  # select "AWS"
+    expect_prompt(tui, "iac", "str", help="Which IaC tool do you use?")
+    tui.sendline(Keyboard.Down)  # select "Cloud Formation"
+    tui.expect_exact(pexpect.EOF)
+
+    answers_file = dst / ".copier-answers.yml"
+
+    assert answers_file.exists()
+    assert yaml.safe_load(answers_file.read_text()) == {
+        "_src_path": str(src),
+        "_commit": "v1",
+        "cloud": "AWS",
+        "iac": "cf",
+    }
+
+    with local.cwd(dst):
+        git_init("v1")
+
+    tui = spawn(COPIER_PATH + ("update", str(dst)), timeout=10)
+    expect_prompt(tui, "cloud", "str", help="Which cloud provider do you use?")
+    tui.sendline(Keyboard.Down)  # select "Azure"
+    expect_prompt(tui, "iac", "str", help="Which IaC tool do you use?")
+    tui.sendline()  # select "Terraform" (first supported)
+    tui.expect_exact(pexpect.EOF)
+
+    assert answers_file.exists()
+    assert yaml.safe_load(answers_file.read_text()) == {
+        "_src_path": str(src),
+        "_commit": "v1",
+        "cloud": "Azure",
+        "iac": "tf",
+    }
+
+
 def test_multiselect_choices_with_templated_default_value(
     tmp_path_factory: pytest.TempPathFactory,
     spawn: Spawn,