Repository: copier-org/copier (python)

Issue:
Inconsistent validator behavior between `copier {copy,recopy}` and `copier update` for computed values
### Describe the problem

Currently, there is a difference in behavior regarding the `validator` expression for computed values (`when: false` with a default value) between the `copier {copy,recopy}` operation and `copier update`. The validator is ignored for `copy`/`recopy` but enforced upon `update`.

This problem came up when configuring a conditional YAML question via `when: <expression>` with a default value where the default value evaluated to an empty string (i.e., `null`/`None` after YAML-parsing) when `when` evaluated to `false` while the validator did not handle the `null`/`None` value correctly and raised an exception upon `copier update`.

The reason for this inconsistency is this: During `copier {copy,recopy}`, only [answers passed via the `-d, --data` (or `--data-file`) flags](https://github.com/copier-org/copier/blob/5d66dc11c6e949ed9c6f4779340200530e791efb/copier/main.py#L494-L504) or [answers from the answers file](https://github.com/copier-org/copier/blob/5d66dc11c6e949ed9c6f4779340200530e791efb/copier/main.py#L475-L482) are validated, so computed values are _not_ validated. But during `copier update`, the [combined answers from the new template's questionnaire are passed to `run_copy()` via the `data` argument](https://github.com/copier-org/copier/blob/66d02da378dcda57fe297f36b5c54dc84ca58ff7/copier/main.py#L1000) including the default value from the `when: false` question, and thus the default value of a `when: false` question does get validated.

A solution to _not_ validate computed values even on `copier update` seems to be as follows – I've tested it locally with an additional test and the test suite is passing as well:

```diff
-                data=self.answers.combined,  # type: ignore[arg-type]
+                data={},
+                user_defaults=self.answers.combined,  # type: ignore[arg-type]
```

But it isn't obvious to me what the _right_ behavior is. Should computed values be validated or not when a validator is present? In any case, the behavior should be consistent.

### Template

[copier-computed-value-validator.zip](https://github.com/user-attachments/files/17059445/copier-computed-value-validator.zip)


### To Reproduce

```console
# Unzip the reproducible example above
unzip copier-computed-value-validator.zip
# Change directory into the extracted root directory `copier-computed-value-validator`
cd copier-computed-value-validator/
# Run `copier copy src/ dst/` with the above template and observe no error
copier copy src/ dst/
# Change directory to the destination directory
cd dst/
# Git-initialize the project and make an initial commit
git init
git add .
git commit -m "initial commit"
# Change directory one level up again
cd ..
# Run `copier update dst/` and observe the error
copier update dst/
```

### Logs

```console
$ copier update dst/
No git tags found in template; using HEAD as ref
Updating to template version 0.0.0.post1.dev0+751d554
No git tags found in template; using HEAD as ref
No git tags found in template; using HEAD as ref
Traceback (most recent call last):
  File "$HOME/.local/bin/copier", line 8, in <module>
    sys.exit(copier_app_run())
             ^^^^^^^^^^^^^^^^
  File "$HOME/.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 "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/plumbum/cli/application.py", line 633, in run
    retcode = inst.main(*tailargs)
              ^^^^^^^^^^^^^^^^^^^^
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 425, in main
    return _handle_exceptions(inner)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 70, in _handle_exceptions
    method()
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 415, in inner
    with self._worker(
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 228, in __exit__
    raise value
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/cli.py", line 423, in inner
    worker.run_update()
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 899, in run_update
    self._apply_update()
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 966, in _apply_update
    with replace(
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 228, in __exit__
    raise value
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 974, in _apply_update
    new_worker.run_copy()
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 817, in run_copy
    self._ask()
  File "$HOME/.local/pipx/venvs/copier/lib/python3.12/site-packages/copier/main.py", line 491, in _ask
    raise ValueError(f"Validation error for question '{var_name}': {err_msg}")
ValueError: Validation error for question 'computed_value': Should this validator be rendered or not?
```


### Expected behavior

Either the validation is skipped on `copier update` or the validation error should also occur on `copier {copy,recopy}`. I'm not sure what's best. Opinions are most welcome.

### Screenshots/screencasts/logs

_No response_

### Operating system

Linux

### Operating system distribution and version

Ubuntu 22.04

### Copier version

copier 9.3.1

### Python version

CPython 3.12

### Installation method

pipx+pypi

### Additional context

_No response_

Pull request:
fix(updating): don't validate computed values
I've fixed the update algorithm to always re-compute computed values in low-level `copy` calls, which fixes #1779 as side effect.

Patch:
diff --git a/copier/main.py b/copier/main.py
index 7f38bda..b616bb0 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -1003,7 +1003,11 @@ class Worker:
             with replace(
                 self,
                 dst_path=new_copy / subproject_subdir,
-                data=self.answers.combined,  # type: ignore[arg-type]
+                data={
+                    k: v
+                    for k, v in self.answers.combined.items()
+                    if k not in self.answers.hidden
+                },
                 defaults=True,
                 quiet=True,
                 src_path=self.subproject.template.url,  # type: ignore[union-attr]


Test patch:
diff --git a/tests/test_recopy.py b/tests/test_recopy.py
index 80a6731..f7460c8 100644
--- a/tests/test_recopy.py
+++ b/tests/test_recopy.py
@@ -1,4 +1,5 @@
 from pathlib import Path
+from textwrap import dedent
 
 import pytest
 import yaml
@@ -98,3 +99,36 @@ def test_recopy_with_skip_answered_and_new_answer(
     run_recopy(dst, data={"boolean": "true"}, skip_answered=True, overwrite=True)
     answers = yaml.safe_load(answers_file.read_text())
     assert answers["boolean"] is True
+
+
+def test_recopy_dont_validate_computed_value(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            src / "copier.yml": dedent(
+                """\
+                computed:
+                    type: str
+                    default: foo
+                    when: false
+                    validator: "This validator should never be rendered"
+                """
+            ),
+            src / "{{ _copier_conf.answers_file }}.jinja": (
+                "{{ _copier_answers|to_nice_yaml }}"
+            ),
+        }
+    )
+    git_save(src)
+    # First copy
+    run_copy(str(src), dst, defaults=True, overwrite=True)
+    git_save(dst)
+    answers_file = dst / ".copier-answers.yml"
+    answers = yaml.safe_load(answers_file.read_text())
+    assert "computed" not in answers
+    # Recopy
+    run_recopy(dst, overwrite=True)
+    answers = yaml.safe_load(answers_file.read_text())
+    assert "computed" not in answers
diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py
index 73774e4..0aca3f4 100644
--- a/tests/test_updatediff.py
+++ b/tests/test_updatediff.py
@@ -1612,3 +1612,43 @@ def test_update_with_skip_answered_and_new_answer(
     run_update(dst, data={"boolean": "true"}, skip_answered=True, overwrite=True)
     answers = yaml.safe_load(answers_file.read_text())
     assert answers["boolean"] is True
+
+
+def test_update_dont_validate_computed_value(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    with local.cwd(src):
+        build_file_tree(
+            {
+                "copier.yml": dedent(
+                    """\
+                    computed:
+                        type: str
+                        default: foo
+                        when: false
+                        validator: "This validator should never be rendered"
+                    """
+                ),
+                "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}",
+            }
+        )
+        git("init")
+        git("add", ".")
+        git("commit", "-m1")
+        git("tag", "v1")
+
+    run_copy(str(src), dst, overwrite=True)
+    answers_file = dst / ".copier-answers.yml"
+    answers = yaml.safe_load(answers_file.read_text())
+    assert "computed" not in answers
+
+    with local.cwd(dst):
+        git("init")
+        git("add", ".")
+        git("commit", "-m1")
+
+    run_update(dst, overwrite=True)
+    answers = yaml.safe_load(answers_file.read_text())
+    assert "computed" not in answers