Repository: copier-org/copier (python)

Issue:
`validator` checks are skipped for questions with a `default` when `--defaults` option is set
### Describe the problem

When using the `--defaults` option the `validator` checks are not executed when a question sets a default and a value isn't explicitly set.

### Template

```yaml
---
title:
  type: str
  help: The human readable title
  validator: "{% if title == '' %}Title must not be empty{% endif %}"

name:
  type: str
  help: Name in kebab-case
  default: "{{ title.lower().replace(' ', '-') }}"
  validator: "{% if not (name | regex_search('^[a-z]+(-[a-z]+)*$')) %}Invalid name{% endif %}"
```

### To Reproduce

1. Create the `copier.yaml` in a directory named `template`
2. `mkdir dest`
3. `copier copy --defaults -d title=@ template foo`

### Logs

```console

```

### Expected behavior

I would expect that the `validator` logic is always run on the value of a question.

### Screenshots/screencasts/logs

_No response_

### Operating system

macOS

### Operating system distribution and version

macOS 15.4.1

### Copier version

9.6.0

### Python version

Python 3.10.13

### Installation method

distro package

### Additional context

_No response_

Pull request:
fix: validate default answers
I've fixed a bug that prevented default answers from being validated against the `validator` expression.

Before, using a default answer through `--defaults` did not apply validation. Now, the `Question.get_default()` method not only casts the answer but also validates it. Initially, this change caused an infinite recursion because [`Question._formatted_choices` used the default answer to pre-select choices for multi-select questions](https://github.com/copier-org/copier/blob/608b5d474be28a7cbaa6518fdebdcb1ee6c1e9cd/copier/_user_data.py#L349-L354) and [`Question.parse_answer()` uses `Question._formatted_choices` to retain the order of choice items in a multi-select question answer](https://github.com/copier-org/copier/blob/608b5d474be28a7cbaa6518fdebdcb1ee6c1e9cd/copier/_user_data.py#L481), so there was a cycle. To avoid this cycle, I've moved the snippet for pre-selecting choices to `Question.get_questionary_structure()`, as it's only important to add this information for prompting. As `Question._formatted_choices` is a cached property, I've decided to deep-copy this list of `Choice` objects to avoid mutating them, risking potentially hard-to-debug side effects.

As it turned out, one [test case](https://github.com/copier-org/copier/pull/1770/files#diff-29da206fb3f96fd729edf6fd15245266be1edf7eb7dcaf13c543c4e92a1e65e7R861-R869) I added to `tests/test_copy.py::test_validate_default_value` in #1770 was _silently_ incorrect and would have caught this bug already back then. :face_with_peeking_eye: This is fixed now as well.

To avoid duplicate error logic and message code, `Question.validate_answer()` now raises a `ValueError` when validation fails instead of returning the error message string. This way, we raise the same exception wherever answer validation fails.

Hands down, this isn't pretty, but it works for now.

Fixes #2143.

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 36c547f..db178de 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ env:
   PYTEST_ADDOPTS: ${{ github.event.inputs.pytest_addopts }}
   PYTHONIOENCODING: "UTF-8"
   # renovate: datasource=pypi depName=uv
-  UV_VERSION: "0.7.3"
+  UV_VERSION: "0.7.6"
 
 jobs:
   build:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 979121f..fb0120a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,7 +7,7 @@ on:
 
 env:
   # renovate: datasource=pypi depName=uv
-  UV_VERSION: "0.7.3"
+  UV_VERSION: "0.7.6"
 
 jobs:
   build:
diff --git a/copier/_main.py b/copier/_main.py
index f5fa1c1..95d2d15 100644
--- a/copier/_main.py
+++ b/copier/_main.py
@@ -555,11 +555,9 @@ class Worker:
             if var_name in self.answers.last:
                 try:
                     answer = question.parse_answer(self.answers.last[var_name])
+                    question.validate_answer(answer)
                 except Exception:
                     del self.answers.last[var_name]
-                else:
-                    if question.validate_answer(answer):
-                        del self.answers.last[var_name]
             # Skip a question when the skip condition is met.
             if not question.get_when():
                 # Omit its answer from the answers file.
@@ -573,14 +571,10 @@ class Worker:
                 if question.default is MISSING:
                     continue
             if var_name in self.answers.init:
-                # Try to parse the answer value.
+                # Try to parse and validate (if the question has a validator)
+                # the answer value.
                 answer = question.parse_answer(self.answers.init[var_name])
-                # Try to validate the answer value if the question has a
-                # validator.
-                if err_msg := question.validate_answer(answer):
-                    raise ValueError(
-                        f"Validation error for question '{var_name}': {err_msg}"
-                    )
+                question.validate_answer(answer)
                 # At this point, the answer value is valid. Do not ask the
                 # question again, but set answer as the user's answer instead.
                 self.answers.user[var_name] = answer
diff --git a/copier/_user_data.py b/copier/_user_data.py
index 5c2e66c..2d190d6 100644
--- a/copier/_user_data.py
+++ b/copier/_user_data.py
@@ -6,6 +6,7 @@ import json
 import warnings
 from collections import ChainMap
 from collections.abc import Mapping, Sequence
+from copy import deepcopy
 from dataclasses import field
 from datetime import datetime
 from functools import cached_property
@@ -275,7 +276,13 @@ class Question:
                     result = self.render_value(
                         self.settings.defaults.get(self.var_name, self.default)
                     )
-        result = self.cast_answer(result)
+        result = self.parse_answer(result)
+        # Computed values (i.e., `when: false`) are intentionally not validated
+        # at the moment.
+        # https://github.com/copier-org/copier/issues/1779#issuecomment-2365006990
+        # https://github.com/copier-org/copier/pull/1785
+        if self.get_when():
+            self.validate_answer(result)
         return result
 
     def get_default_rendered(self) -> bool | str | Choice | None | MissingType:
@@ -319,7 +326,6 @@ class Question:
         """Obtain choices rendered and properly formatted."""
         result = []
         choices = self.choices
-        default = self.get_default()
         if isinstance(choices, str):
             choices = parse_yaml_string(self.render_value(self.choices))
         if isinstance(choices, dict):
@@ -344,15 +350,7 @@ class Question:
 
                 disabled = self.render_value(value.get("validator", ""))
                 value = value["value"]
-            # The value can be templated
-            value = self.render_value(value)
-            checked = (
-                self.multiselect
-                and isinstance(default, list)
-                and self.cast_answer(value) in default
-                or None
-            )
-            c = Choice(name, value, disabled=disabled, checked=checked)
+            c = Choice(name, self.render_value(value), disabled=disabled)
             # Try to cast the value according to the question's type to raise
             # an error in case the value is incompatible.
             self.cast_answer(c.value)
@@ -382,7 +380,11 @@ class Question:
                 ans = self.parse_answer(answer)
             except Exception:
                 return "Invalid input"
-            return self.validate_answer(ans) or True
+            try:
+                self.validate_answer(ans)
+            except Exception as exc:
+                return str(exc)
+            return True
 
         lexer = None
         result: AnyByStrDict = {
@@ -405,7 +407,14 @@ class Question:
                 result["default"] = False
         if self.choices:
             questionary_type = "checkbox" if self.multiselect else "select"
-            result["choices"] = self._formatted_choices
+            choices = self._formatted_choices
+            # Select default choices for a multiselect question.
+            if self.multiselect and isinstance(
+                default_choices := self.get_default(), list
+            ):
+                for choice in (choices := deepcopy(choices)):
+                    choice.checked = self.cast_answer(choice.value) in default_choices
+            result["choices"] = choices
         if questionary_type == "input":
             if self.secret:
                 questionary_type = "password"
@@ -436,15 +445,16 @@ class Question:
         """Get the value for multiline."""
         return cast_to_bool(self.render_value(self.multiline))
 
-    def validate_answer(self, answer: Any) -> str:
+    def validate_answer(self, answer: Any) -> None:
         """Validate user answer."""
         try:
             err_msg = self.render_value(self.validator, {self.var_name: answer}).strip()
         except Exception as error:
-            return str(error)
+            err_msg = str(error)
         if err_msg:
-            return err_msg
-        return ""
+            raise ValueError(
+                f"Validation error for question '{self.var_name}': {err_msg}"
+            )
 
     def get_when(self) -> bool:
         """Get skip condition for question."""
diff --git a/docs/configuring.md b/docs/configuring.md
index 65ab6e2..9782d67 100644
--- a/docs/configuring.md
+++ b/docs/configuring.md
@@ -1426,6 +1426,19 @@ configuring `secret: true` in the [advanced prompt format][advanced-prompt-forma
     password: s3cr3t
     ```
 
+### `skip_answered`
+
+-   Format: `bool`
+-   CLI flags: `-A`, `--skip-answered` (only available in `copier update` subcommand)
+-   Default value: `False`
+
+When updating a project, skip asking questions that have already been answered and keep
+the recorded answer.
+
+!!! info
+
+    Not supported in `copier.yml`.
+
 ### `skip_if_exists`
 
 -   Format: `List[str]`
diff --git a/pyproject.toml b/pyproject.toml
index 408fb69..1654338 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,7 +45,7 @@ copier = "copier.__main__:CopierApp.run"
 
 [dependency-groups]
 dev = [
-  "commitizen==4.7.0",
+  "commitizen==4.7.2",
   "mypy==1.15.0",
   "pexpect==4.9.0",
   "poethepoet==0.34.0",
@@ -54,12 +54,12 @@ dev = [
   "pytest-cov==6.1.1",
   "pytest-gitconfig==0.7.0",
   "pytest-xdist==3.6.1",
-  "ruff==0.11.9",
+  "ruff==0.11.10",
   "taplo==0.9.3",
   "types-backports==0.1.3",
   "types-colorama==0.4.15.20240311",
-  "types-pygments==2.19.0.20250514",
-  "types-pyyaml==6.0.12.20250402",
+  "types-pygments==2.19.0.20250516",
+  "types-pyyaml==6.0.12.20250516",
   "typing-extensions==4.13.2; python_version < '<3.10'",
 ]
 docs = [
diff --git a/uv.lock b/uv.lock
index d281411..7af60aa 100644
--- a/uv.lock
+++ b/uv.lock
@@ -157,7 +157,7 @@ wheels = [
 
 [[package]]
 name = "commitizen"
-version = "4.7.0"
+version = "4.7.2"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "argcomplete" },
@@ -173,9 +173,9 @@ dependencies = [
     { name = "tomlkit" },
     { name = "typing-extensions", marker = "python_full_version < '3.11'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/7a/2c2a781b3e227f528b19e1144efcfdd61f7414bf9cf84ba6bbdc215f3427/commitizen-4.7.0.tar.gz", hash = "sha256:ef95f2ef354b438dce7c6164e5d47d10cc377df666ee65a116bcfcb146bb0c0a", size = 53254, upload-time = "2025-05-10T13:47:33.585Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/2b/0dcd3ab025dafd3353cf8051f5859ce7f75b86e90e2b3c0395304fbbbfa3/commitizen-4.7.2.tar.gz", hash = "sha256:e5ca28f909c3b3d28575b294561feeee21e33c864c7362babfe0e7798ae11aae", size = 53356, upload-time = "2025-05-18T15:17:49.7Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/27/2d/0ae2129dd1071f16fffa373f9223971f84808a7fee85c0cefc1db2ffaec8/commitizen-4.7.0-py3-none-any.whl", hash = "sha256:2d274e013a6e09bc69f97fe64d6a6389926e2a22f5a4a19b16571d5b4c31083c", size = 76010, upload-time = "2025-05-10T13:47:31.75Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/8a/02db37d46e17f8f45fa0a61159296fa58b58a2303440e08bc797b942e659/commitizen-4.7.2-py3-none-any.whl", hash = "sha256:eb3cd2da0690c6edbb8c11e586d3f07b09e9795424aa51f5413ed89827927623", size = 76192, upload-time = "2025-05-18T15:17:48.39Z" },
 ]
 
 [[package]]
@@ -245,7 +245,7 @@ requires-dist = [
 
 [package.metadata.requires-dev]
 dev = [
-    { name = "commitizen", specifier = "==4.7.0" },
+    { name = "commitizen", specifier = "==4.7.2" },
     { name = "mypy", specifier = "==1.15.0" },
     { name = "pexpect", specifier = "==4.9.0" },
     { name = "poethepoet", specifier = "==0.34.0" },
@@ -254,12 +254,12 @@ dev = [
     { name = "pytest-cov", specifier = "==6.1.1" },
     { name = "pytest-gitconfig", specifier = "==0.7.0" },
     { name = "pytest-xdist", specifier = "==3.6.1" },
-    { name = "ruff", specifier = "==0.11.9" },
+    { name = "ruff", specifier = "==0.11.10" },
     { name = "taplo", specifier = "==0.9.3" },
     { name = "types-backports", specifier = "==0.1.3" },
     { name = "types-colorama", specifier = "==0.4.15.20240311" },
-    { name = "types-pygments", specifier = "==2.19.0.20250514" },
-    { name = "types-pyyaml", specifier = "==6.0.12.20250402" },
+    { name = "types-pygments", specifier = "==2.19.0.20250516" },
+    { name = "types-pyyaml", specifier = "==6.0.12.20250516" },
     { name = "typing-extensions", specifier = "==4.13.2" },
 ]
 docs = [
@@ -1249,27 +1249,27 @@ wheels = [
 
 [[package]]
 name = "ruff"
-version = "0.11.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f5/e7/e55dda1c92cdcf34b677ebef17486669800de01e887b7831a1b8fdf5cb08/ruff-0.11.9.tar.gz", hash = "sha256:ebd58d4f67a00afb3a30bf7d383e52d0e036e6195143c6db7019604a05335517", size = 4132134, upload-time = "2025-05-09T16:19:41.511Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/fb/71/75dfb7194fe6502708e547941d41162574d1f579c4676a8eb645bf1a6842/ruff-0.11.9-py3-none-linux_armv6l.whl", hash = "sha256:a31a1d143a5e6f499d1fb480f8e1e780b4dfdd580f86e05e87b835d22c5c6f8c", size = 10335453, upload-time = "2025-05-09T16:18:58.2Z" },
-    { url = "https://files.pythonhosted.org/packages/74/fc/ad80c869b1732f53c4232bbf341f33c5075b2c0fb3e488983eb55964076a/ruff-0.11.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:66bc18ca783b97186a1f3100e91e492615767ae0a3be584e1266aa9051990722", size = 11072566, upload-time = "2025-05-09T16:19:01.432Z" },
-    { url = "https://files.pythonhosted.org/packages/87/0d/0ccececef8a0671dae155cbf7a1f90ea2dd1dba61405da60228bbe731d35/ruff-0.11.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd576cd06962825de8aece49f28707662ada6a1ff2db848d1348e12c580acbf1", size = 10435020, upload-time = "2025-05-09T16:19:03.897Z" },
-    { url = "https://files.pythonhosted.org/packages/52/01/e249e1da6ad722278094e183cbf22379a9bbe5f21a3e46cef24ccab76e22/ruff-0.11.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1d18b4be8182cc6fddf859ce432cc9631556e9f371ada52f3eaefc10d878de", size = 10593935, upload-time = "2025-05-09T16:19:06.455Z" },
-    { url = "https://files.pythonhosted.org/packages/ed/9a/40cf91f61e3003fe7bd43f1761882740e954506c5a0f9097b1cff861f04c/ruff-0.11.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f3f46f759ac623e94824b1e5a687a0df5cd7f5b00718ff9c24f0a894a683be7", size = 10172971, upload-time = "2025-05-09T16:19:10.261Z" },
-    { url = "https://files.pythonhosted.org/packages/61/12/d395203de1e8717d7a2071b5a340422726d4736f44daf2290aad1085075f/ruff-0.11.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f34847eea11932d97b521450cf3e1d17863cfa5a94f21a056b93fb86f3f3dba2", size = 11748631, upload-time = "2025-05-09T16:19:12.307Z" },
-    { url = "https://files.pythonhosted.org/packages/66/d6/ef4d5eba77677eab511644c37c55a3bb8dcac1cdeb331123fe342c9a16c9/ruff-0.11.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f33b15e00435773df97cddcd263578aa83af996b913721d86f47f4e0ee0ff271", size = 12409236, upload-time = "2025-05-09T16:19:15.006Z" },
-    { url = "https://files.pythonhosted.org/packages/c5/8f/5a2c5fc6124dd925a5faf90e1089ee9036462118b619068e5b65f8ea03df/ruff-0.11.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b27613a683b086f2aca8996f63cb3dd7bc49e6eccf590563221f7b43ded3f65", size = 11881436, upload-time = "2025-05-09T16:19:17.063Z" },
-    { url = "https://files.pythonhosted.org/packages/39/d1/9683f469ae0b99b95ef99a56cfe8c8373c14eba26bd5c622150959ce9f64/ruff-0.11.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e0d88756e63e8302e630cee3ce2ffb77859797cc84a830a24473939e6da3ca6", size = 13982759, upload-time = "2025-05-09T16:19:19.693Z" },
-    { url = "https://files.pythonhosted.org/packages/4e/0b/c53a664f06e0faab596397867c6320c3816df479e888fe3af63bc3f89699/ruff-0.11.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:537c82c9829d7811e3aa680205f94c81a2958a122ac391c0eb60336ace741a70", size = 11541985, upload-time = "2025-05-09T16:19:21.831Z" },
-    { url = "https://files.pythonhosted.org/packages/23/a0/156c4d7e685f6526a636a60986ee4a3c09c8c4e2a49b9a08c9913f46c139/ruff-0.11.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:440ac6a7029f3dee7d46ab7de6f54b19e34c2b090bb4f2480d0a2d635228f381", size = 10465775, upload-time = "2025-05-09T16:19:24.401Z" },
-    { url = "https://files.pythonhosted.org/packages/43/d5/88b9a6534d9d4952c355e38eabc343df812f168a2c811dbce7d681aeb404/ruff-0.11.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:71c539bac63d0788a30227ed4d43b81353c89437d355fdc52e0cda4ce5651787", size = 10170957, upload-time = "2025-05-09T16:19:27.08Z" },
-    { url = "https://files.pythonhosted.org/packages/f0/b8/2bd533bdaf469dc84b45815ab806784d561fab104d993a54e1852596d581/ruff-0.11.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c67117bc82457e4501473c5f5217d49d9222a360794bfb63968e09e70f340abd", size = 11143307, upload-time = "2025-05-09T16:19:29.462Z" },
-    { url = "https://files.pythonhosted.org/packages/2f/d9/43cfba291788459b9bfd4e09a0479aa94d05ab5021d381a502d61a807ec1/ruff-0.11.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e4b78454f97aa454586e8a5557facb40d683e74246c97372af3c2d76901d697b", size = 11603026, upload-time = "2025-05-09T16:19:31.569Z" },
-    { url = "https://files.pythonhosted.org/packages/22/e6/7ed70048e89b01d728ccc950557a17ecf8df4127b08a56944b9d0bae61bc/ruff-0.11.9-py3-none-win32.whl", hash = "sha256:7fe1bc950e7d7b42caaee2a8a3bc27410547cc032c9558ee2e0f6d3b209e845a", size = 10548627, upload-time = "2025-05-09T16:19:33.657Z" },
-    { url = "https://files.pythonhosted.org/packages/90/36/1da5d566271682ed10f436f732e5f75f926c17255c9c75cefb77d4bf8f10/ruff-0.11.9-py3-none-win_amd64.whl", hash = "sha256:52edaa4a6d70f8180343a5b7f030c7edd36ad180c9f4d224959c2d689962d964", size = 11634340, upload-time = "2025-05-09T16:19:35.815Z" },
-    { url = "https://files.pythonhosted.org/packages/40/f7/70aad26e5877c8f7ee5b161c4c9fa0100e63fc4c944dc6d97b9c7e871417/ruff-0.11.9-py3-none-win_arm64.whl", hash = "sha256:bcf42689c22f2e240f496d0c183ef2c6f7b35e809f12c1db58f75d9aa8d630ca", size = 10741080, upload-time = "2025-05-09T16:19:39.605Z" },
+version = "0.11.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/4a3c5a97faaae6b428b336dcca81d03ad04779f8072c267ad2bd860126bf/ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6", size = 4165632, upload-time = "2025-05-15T14:08:56.76Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2f/9f/596c628f8824a2ce4cd12b0f0b4c0629a62dfffc5d0f742c19a1d71be108/ruff-0.11.10-py3-none-linux_armv6l.whl", hash = "sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58", size = 10316243, upload-time = "2025-05-15T14:08:12.884Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/38/c1e0b77ab58b426f8c332c1d1d3432d9fc9a9ea622806e208220cb133c9e/ruff-0.11.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed", size = 11083636, upload-time = "2025-05-15T14:08:16.551Z" },
+    { url = "https://files.pythonhosted.org/packages/23/41/b75e15961d6047d7fe1b13886e56e8413be8467a4e1be0a07f3b303cd65a/ruff-0.11.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca", size = 10441624, upload-time = "2025-05-15T14:08:19.032Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/2c/e396b6703f131406db1811ea3d746f29d91b41bbd43ad572fea30da1435d/ruff-0.11.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2", size = 10624358, upload-time = "2025-05-15T14:08:21.542Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/8c/ee6cca8bdaf0f9a3704796022851a33cd37d1340bceaf4f6e991eb164e2e/ruff-0.11.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5", size = 10176850, upload-time = "2025-05-15T14:08:23.682Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/ce/4e27e131a434321b3b7c66512c3ee7505b446eb1c8a80777c023f7e876e6/ruff-0.11.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641", size = 11759787, upload-time = "2025-05-15T14:08:25.733Z" },
+    { url = "https://files.pythonhosted.org/packages/58/de/1e2e77fc72adc7cf5b5123fd04a59ed329651d3eab9825674a9e640b100b/ruff-0.11.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947", size = 12430479, upload-time = "2025-05-15T14:08:28.013Z" },
+    { url = "https://files.pythonhosted.org/packages/07/ed/af0f2340f33b70d50121628ef175523cc4c37619e98d98748c85764c8d88/ruff-0.11.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4", size = 11919760, upload-time = "2025-05-15T14:08:30.956Z" },
+    { url = "https://files.pythonhosted.org/packages/24/09/d7b3d3226d535cb89234390f418d10e00a157b6c4a06dfbe723e9322cb7d/ruff-0.11.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f", size = 14041747, upload-time = "2025-05-15T14:08:33.297Z" },
+    { url = "https://files.pythonhosted.org/packages/62/b3/a63b4e91850e3f47f78795e6630ee9266cb6963de8f0191600289c2bb8f4/ruff-0.11.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b", size = 11550657, upload-time = "2025-05-15T14:08:35.639Z" },
+    { url = "https://files.pythonhosted.org/packages/46/63/a4f95c241d79402ccdbdb1d823d156c89fbb36ebfc4289dce092e6c0aa8f/ruff-0.11.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2", size = 10489671, upload-time = "2025-05-15T14:08:38.437Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/9b/c2238bfebf1e473495659c523d50b1685258b6345d5ab0b418ca3f010cd7/ruff-0.11.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523", size = 10160135, upload-time = "2025-05-15T14:08:41.247Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/ef/ba7251dd15206688dbfba7d413c0312e94df3b31b08f5d695580b755a899/ruff-0.11.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125", size = 11170179, upload-time = "2025-05-15T14:08:43.762Z" },
+    { url = "https://files.pythonhosted.org/packages/73/9f/5c336717293203ba275dbfa2ea16e49b29a9fd9a0ea8b6febfc17e133577/ruff-0.11.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad", size = 11626021, upload-time = "2025-05-15T14:08:46.451Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/2b/162fa86d2639076667c9aa59196c020dc6d7023ac8f342416c2f5ec4bda0/ruff-0.11.10-py3-none-win32.whl", hash = "sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19", size = 10494958, upload-time = "2025-05-15T14:08:49.601Z" },
+    { url = "https://files.pythonhosted.org/packages/24/f3/66643d8f32f50a4b0d09a4832b7d919145ee2b944d43e604fbd7c144d175/ruff-0.11.10-py3-none-win_amd64.whl", hash = "sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224", size = 11650285, upload-time = "2025-05-15T14:08:52.392Z" },
+    { url = "https://files.pythonhosted.org/packages/95/3a/2e8704d19f376c799748ff9cb041225c1d59f3e7711bc5596c8cfdc24925/ruff-0.11.10-py3-none-win_arm64.whl", hash = "sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1", size = 10765278, upload-time = "2025-05-15T14:08:54.56Z" },
 ]
 
 [[package]]
@@ -1381,23 +1381,23 @@ wheels = [
 
 [[package]]
 name = "types-pygments"
-version = "2.19.0.20250514"
+version = "2.19.0.20250516"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "types-docutils" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/53/35/1c3fd20ee277eab6314c4e964107b0cc434428a944f1ac6a51c30549fc74/types_pygments-2.19.0.20250514.tar.gz", hash = "sha256:a39eafb8ce0df126455a2aeba4924e8dbb19c01c20ec159341d49b3ddc61844b", size = 18489, upload-time = "2025-05-14T03:05:08.622Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/71/9a/c1ea3f59001e9d13b93ec8acf02c75b47832423f17471295b8ceebc48a65/types_pygments-2.19.0.20250516.tar.gz", hash = "sha256:b53fd07e197f0e7be38ee19598bd99c78be5ca5f9940849c843be74a2f81ab58", size = 18485, upload-time = "2025-05-16T03:09:30.05Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/fb/4e/54dabf924b5b1ec9e018063f26ba03e8c9ed4d461bb88cd31c3fb5cd8b80/types_pygments-2.19.0.20250514-py3-none-any.whl", hash = "sha256:81b60d109390b9a369cb253b099ef8b1826cf7c30ddca8ff2819a64a17193a7c", size = 25595, upload-time = "2025-05-14T03:05:07.298Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/0b/32ce3ad35983bf4f603c43cfb00559b37bb5ed90ac4ef9f1d5564b8e4034/types_pygments-2.19.0.20250516-py3-none-any.whl", hash = "sha256:db27de8b59591389cd7d14792483892c021c73b8389ef55fef40a48aa371fbcc", size = 25440, upload-time = "2025-05-16T03:09:29.185Z" },
 ]
 
 [[package]]
 name = "types-pyyaml"
-version = "6.0.12.20250402"
+version = "6.0.12.20250516"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2d/68/609eed7402f87c9874af39d35942744e39646d1ea9011765ec87b01b2a3c/types_pyyaml-6.0.12.20250402.tar.gz", hash = "sha256:d7c13c3e6d335b6af4b0122a01ff1d270aba84ab96d1a1a1063ecba3e13ec075", size = 17282, upload-time = "2025-04-02T02:56:00.235Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/22/59e2aeb48ceeee1f7cd4537db9568df80d62bdb44a7f9e743502ea8aab9c/types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba", size = 17378, upload-time = "2025-05-16T03:08:04.897Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ed/56/1fe61db05685fbb512c07ea9323f06ea727125951f1eb4dff110b3311da3/types_pyyaml-6.0.12.20250402-py3-none-any.whl", hash = "sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681", size = 20329, upload-time = "2025-04-02T02:55:59.382Z" },
+    { url = "https://files.pythonhosted.org/packages/99/5f/e0af6f7f6a260d9af67e1db4f54d732abad514252a7a378a6c4d17dd1036/types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530", size = 20312, upload-time = "2025-05-16T03:08:04.019Z" },
 ]
 
 [[package]]


Test patch:
diff --git a/tests/test_copy.py b/tests/test_copy.py
index 2827866..144671b 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -852,93 +852,89 @@ def test_required_choice_question_without_data(
 
 
 @pytest.mark.parametrize(
-    "type_name, default, expected",
+    "spec, expected",
     [
-        ("str", "string", does_not_raise()),
-        ("str", "1.0", does_not_raise()),
-        ("str", 1.0, does_not_raise()),
-        ("str", None, pytest.raises(TypeError)),
+        ({"type": "str", "default": "string"}, does_not_raise()),
+        ({"type": "str", "default": "1.0"}, does_not_raise()),
+        ({"type": "str", "default": 1.0}, does_not_raise()),
+        ({"type": "str", "default": None}, pytest.raises(TypeError)),
         (
             {
                 "type": "str",
+                "default": "",
+                "validator": "[% if q|length < 3 %]too short[% endif %]",
+            },
+            pytest.raises(ValueError),
+        ),
+        (
+            {
+                "type": "str",
+                "default": "",
                 "secret": True,
                 "validator": "[% if q|length < 3 %]too short[% endif %]",
             },
-            "",
             pytest.raises(ValueError),
         ),
-        ("int", 1, does_not_raise()),
-        ("int", 1.0, does_not_raise()),
-        ("int", "1", does_not_raise()),
-        ("int", "1.0", pytest.raises(ValueError)),
-        ("int", "no-int", pytest.raises(ValueError)),
-        ("int", None, pytest.raises(TypeError)),
-        ("int", {}, pytest.raises(TypeError)),
-        ("int", [], pytest.raises(TypeError)),
-        ("float", 1.1, does_not_raise()),
-        ("float", 1, does_not_raise()),
-        ("float", "1.1", does_not_raise()),
-        ("float", "no-float", pytest.raises(ValueError)),
-        ("float", None, pytest.raises(TypeError)),
-        ("float", {}, pytest.raises(TypeError)),
-        ("float", [], pytest.raises(TypeError)),
-        ("bool", True, does_not_raise()),
-        ("bool", False, does_not_raise()),
-        ("bool", "y", does_not_raise()),
-        ("bool", "n", does_not_raise()),
-        ("bool", None, pytest.raises(TypeError)),
-        ("json", '"string"', does_not_raise()),
-        ("json", "1", does_not_raise()),
-        ("json", 1, does_not_raise()),
-        ("json", "1.1", does_not_raise()),
-        ("json", 1.1, does_not_raise()),
-        ("json", "true", does_not_raise()),
-        ("json", True, does_not_raise()),
-        ("json", "false", does_not_raise()),
-        ("json", False, does_not_raise()),
-        ("json", "{}", does_not_raise()),
-        ("json", {}, does_not_raise()),
-        ("json", "[]", does_not_raise()),
-        ("json", [], does_not_raise()),
-        ("json", "null", does_not_raise()),
-        ("json", None, does_not_raise()),
-        ("yaml", '"string"', does_not_raise()),
-        ("yaml", "string", does_not_raise()),
-        ("yaml", "1", does_not_raise()),
-        ("yaml", 1, does_not_raise()),
-        ("yaml", "1.1", does_not_raise()),
-        ("yaml", 1.1, does_not_raise()),
-        ("yaml", "true", does_not_raise()),
-        ("yaml", True, does_not_raise()),
-        ("yaml", "false", does_not_raise()),
-        ("yaml", False, does_not_raise()),
-        ("yaml", "{}", does_not_raise()),
-        ("yaml", {}, does_not_raise()),
-        ("yaml", "[]", does_not_raise()),
-        ("yaml", [], does_not_raise()),
-        ("yaml", "null", does_not_raise()),
-        ("yaml", None, does_not_raise()),
+        ({"type": "int", "default": 1}, does_not_raise()),
+        ({"type": "int", "default": 1.0}, does_not_raise()),
+        ({"type": "int", "default": "1"}, does_not_raise()),
+        ({"type": "int", "default": "1.0"}, pytest.raises(ValueError)),
+        ({"type": "int", "default": "no-int"}, pytest.raises(ValueError)),
+        ({"type": "int", "default": None}, pytest.raises(TypeError)),
+        ({"type": "int", "default": {}}, pytest.raises(TypeError)),
+        ({"type": "int", "default": []}, pytest.raises(TypeError)),
+        ({"type": "float", "default": 1.1}, does_not_raise()),
+        ({"type": "float", "default": 1}, does_not_raise()),
+        ({"type": "float", "default": "1.1"}, does_not_raise()),
+        ({"type": "float", "default": "no-float"}, pytest.raises(ValueError)),
+        ({"type": "float", "default": None}, pytest.raises(TypeError)),
+        ({"type": "float", "default": {}}, pytest.raises(TypeError)),
+        ({"type": "float", "default": []}, pytest.raises(TypeError)),
+        ({"type": "bool", "default": True}, does_not_raise()),
+        ({"type": "bool", "default": False}, does_not_raise()),
+        ({"type": "bool", "default": "y"}, does_not_raise()),
+        ({"type": "bool", "default": "n"}, does_not_raise()),
+        ({"type": "bool", "default": None}, pytest.raises(TypeError)),
+        ({"type": "json", "default": '"string"'}, does_not_raise()),
+        ({"type": "json", "default": "1"}, does_not_raise()),
+        ({"type": "json", "default": 1}, does_not_raise()),
+        ({"type": "json", "default": "1.1"}, does_not_raise()),
+        ({"type": "json", "default": 1.1}, does_not_raise()),
+        ({"type": "json", "default": "true"}, does_not_raise()),
+        ({"type": "json", "default": True}, does_not_raise()),
+        ({"type": "json", "default": "false"}, does_not_raise()),
+        ({"type": "json", "default": False}, does_not_raise()),
+        ({"type": "json", "default": "{}"}, does_not_raise()),
+        ({"type": "json", "default": {}}, does_not_raise()),
+        ({"type": "json", "default": "[]"}, does_not_raise()),
+        ({"type": "json", "default": []}, does_not_raise()),
+        ({"type": "json", "default": "null"}, does_not_raise()),
+        ({"type": "json", "default": None}, does_not_raise()),
+        ({"type": "yaml", "default": '"string"'}, does_not_raise()),
+        ({"type": "yaml", "default": "string"}, does_not_raise()),
+        ({"type": "yaml", "default": "1"}, does_not_raise()),
+        ({"type": "yaml", "default": 1}, does_not_raise()),
+        ({"type": "yaml", "default": "1.1"}, does_not_raise()),
+        ({"type": "yaml", "default": 1.1}, does_not_raise()),
+        ({"type": "yaml", "default": "true"}, does_not_raise()),
+        ({"type": "yaml", "default": True}, does_not_raise()),
+        ({"type": "yaml", "default": "false"}, does_not_raise()),
+        ({"type": "yaml", "default": False}, does_not_raise()),
+        ({"type": "yaml", "default": "{}"}, does_not_raise()),
+        ({"type": "yaml", "default": {}}, does_not_raise()),
+        ({"type": "yaml", "default": "[]"}, does_not_raise()),
+        ({"type": "yaml", "default": []}, does_not_raise()),
+        ({"type": "yaml", "default": "null"}, does_not_raise()),
+        ({"type": "yaml", "default": None}, does_not_raise()),
     ],
 )
 def test_validate_default_value(
     tmp_path_factory: pytest.TempPathFactory,
-    type_name: str,
-    default: Any,
+    spec: AnyByStrDict,
     expected: AbstractContextManager[None],
 ) -> None:
     src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
-    build_file_tree(
-        {
-            (src / "copier.yml"): yaml.dump(
-                {
-                    "q": {
-                        "type": type_name,
-                        "default": default,
-                    }
-                }
-            )
-        }
-    )
+    build_file_tree({(src / "copier.yml"): yaml.dump({"q": spec})})
     with expected:
         copier.run_copy(str(src), dst, defaults=True)