From 732cbcda60c58fc3a55e5cfdae46b8ecac2befd2 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Thu, 8 Jan 2026 20:46:25 +0000 Subject: [PATCH 01/20] feat(copier/_user_data.py): add `questionary` `use_shorcuts` & `use_filter_search` to `Question` class for selection with number shortcuts or filter Refs: #2155 --- copier/_user_data.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/copier/_user_data.py b/copier/_user_data.py index b79fc5564..5ccc0dc43 100644 --- a/copier/_user_data.py +++ b/copier/_user_data.py @@ -221,6 +221,8 @@ class Question: type: str = Field(default="", validate_default=True) validator: str = "" when: str | bool = True + use_shortcuts: bool = False + use_filter_search: bool = False @field_validator("var_name") @classmethod @@ -419,6 +421,13 @@ def _validate(answer: str) -> str | Literal[True]: result["default"] = False if self.choices: questionary_type = "checkbox" if self.multiselect else "select" + + if self.use_filter_search: + result["use_search_filter"] = True + result["use_jk_keys"] = False + elif self.use_shortcuts and questionary_type == "select": + result["use_shortcuts"] = True + choices = self._formatted_choices # Select default choices for a multiselect question. if self.multiselect and isinstance( From 06c942f546abe588e3d5a37edbfb952b340f0f15 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Thu, 8 Jan 2026 20:48:56 +0000 Subject: [PATCH 02/20] docs(copier/_user_data.py): complement `Question` docstring to doc `use_shortcuts` & `use_filter_search` --- copier/_user_data.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/copier/_user_data.py b/copier/_user_data.py index 5ccc0dc43..3aa71c0c3 100644 --- a/copier/_user_data.py +++ b/copier/_user_data.py @@ -203,6 +203,12 @@ class Question: If it is a boolean, it is used directly. If it is a str, it is converted to boolean using a parser similar to YAML, but only for boolean values. + + use_shortcuts: + Condition that, if `True`, will use `use_shortcuts` in `select` question, allowing for selection via automatically numbered shortcut. Will be deactivated if `use_filter_search` is `True`. + + use_filter_search: + Condition that, if `True`, uses `use_search_filter` in `checkbox`/`select` question while deactivating `use_jk_keys`, allowing for selection via filtering. """ var_name: str From 75bcd238188b0e00f207dc05deb7ade8956f49f0 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Thu, 8 Jan 2026 20:52:25 +0000 Subject: [PATCH 03/20] test(copier/_user_data.py): add tests for `Question` `use_shortcuts` & `use_filter_search` --- tests/test_shortcut.py | 234 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/test_shortcut.py diff --git a/tests/test_shortcut.py b/tests/test_shortcut.py new file mode 100644 index 000000000..f592fd8e9 --- /dev/null +++ b/tests/test_shortcut.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from collections import deque +from textwrap import dedent + +import pexpect +import pytest +from plumbum import local + +from .helpers import ( + BRACKET_ENVOPS, + BRACKET_ENVOPS_JSON, + COPIER_PATH, + SUFFIX_TMPL, + Keyboard, + Spawn, + build_file_tree, + expect_prompt, +) + +BLK_START = BRACKET_ENVOPS["block_start_string"] +BLK_END = BRACKET_ENVOPS["block_end_string"] + + +@pytest.mark.parametrize( + "copier_file, input_select, result", + [ + ( + f"""\ + _templates_suffix: {SUFFIX_TMPL} + _envops: {BRACKET_ENVOPS_JSON} + select: + type: str + help: Select one option only + use_shortcuts: true + default: first + choices: + one: first + two: second + three: third + checkbox: + type: str + help: Select any + multiselect: true + use_filter_search: true + choices: + one: first + two: second + three: third + four: fourth + """, + "3", + dedent( + """\ + select: "third" + checkbox: ["third"] + """, + ), + ), + ( + f"""\ + _templates_suffix: {SUFFIX_TMPL} + _envops: {BRACKET_ENVOPS_JSON} + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + checkbox: + type: str + help: Select any + multiselect: true + choices: + one: first + two: second + three: third + four: fourth + """, + "3", + dedent( + """\ + select: "first" + checkbox: ["first"] + """ + ), + ), + ( + f"""\ + _templates_suffix: {SUFFIX_TMPL} + _envops: {BRACKET_ENVOPS_JSON} + select: + type: str + help: Select one option only + use_shortcuts: true + default: first + choices: + one: first + two: second + three: third + checkbox: + type: str + help: Select any + multiselect: true + choices: + one: first + two: second + three: third + four: fourth + """, + "3", + dedent( + """\ + select: "third" + checkbox: ["first"] + """ + ), + ), + ( + f"""\ + _templates_suffix: {SUFFIX_TMPL} + _envops: {BRACKET_ENVOPS_JSON} + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + checkbox: + type: str + help: Select any + multiselect: true + use_filter_search: true + choices: + one: first + two: second + three: third + four: fourth + """, + "3", + dedent( + """\ + select: "first" + checkbox: ["third"] + """ + ), + ), + ( + f"""\ + _templates_suffix: {SUFFIX_TMPL} + _envops: {BRACKET_ENVOPS_JSON} + select: + type: str + help: Select one option only + use_filter_search: true + default: first + choices: + one: first + two: second + three: third + checkbox: + type: str + help: Select any + multiselect: true + choices: + one: first + two: second + three: third + four: fourth + """, + "tw", + dedent( + """\ + select: "second" + checkbox: ["first"] + """ + ), + ), + ], +) +def test_shortcuts( + tmp_path_factory: pytest.TempPathFactory, + copier_file: str, + input_select: str, + result: str, + spawn: Spawn, +) -> None: + """Test shortcuts.""" + # tui = spawn(COPIER_PATH + ("copy", path, str(tmp_path))) + + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + with local.cwd(src): + build_file_tree( + { + "copier.yml": copier_file, + "results.txt.tmpl": """\ + select: [[select|tojson]] + checkbox: [[checkbox|tojson]] + """, + } + ) + + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + + expect_prompt(tui, "select", "str", help="Select one option only") + deque( + map( + tui.expect_exact, + [ + "one", + "two", + "three", + ], + ) + ) + tui.send(input_select) + tui.send(Keyboard.Enter) + + expect_prompt(tui, "checkbox", "str", help="Select any") + deque( + map( + tui.expect_exact, + ["one", "two", "three", "four"], + ) + ) + tui.send("th ") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "results.txt").read_text() == result From 08371bf7017fff355b895527fd55b6a5841f1aeb Mon Sep 17 00:00:00 2001 From: RR5555 Date: Sun, 11 Jan 2026 05:18:33 +0000 Subject: [PATCH 04/20] test(tests/): rename `test_shortcut.py` to `test_choices.py` Per reviewer suggestion/request Reviewed-by: sisp Refs: #2446#discussion_r2678784231 --- tests/{test_shortcut.py => test_choices.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_shortcut.py => test_choices.py} (100%) diff --git a/tests/test_shortcut.py b/tests/test_choices.py similarity index 100% rename from tests/test_shortcut.py rename to tests/test_choices.py From 13d341b41ab4b637c26345f2c29bb9ab5c11a7d2 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Sun, 11 Jan 2026 06:45:14 +0000 Subject: [PATCH 05/20] fix(copier/_user_data.py): fix attribute name of `Question` from `use_filter_search` to `use_search_filter` Per reviewer catch to match `questionary` args as intended Reviewed-by: sisp Refs: #2446#discussion_r2678785014 --- copier/_user_data.py | 8 ++++---- tests/test_choices.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/copier/_user_data.py b/copier/_user_data.py index 3aa71c0c3..2d3fa00cb 100644 --- a/copier/_user_data.py +++ b/copier/_user_data.py @@ -205,9 +205,9 @@ class Question: boolean values. use_shortcuts: - Condition that, if `True`, will use `use_shortcuts` in `select` question, allowing for selection via automatically numbered shortcut. Will be deactivated if `use_filter_search` is `True`. + Condition that, if `True`, will use `use_shortcuts` in `select` question, allowing for selection via automatically numbered shortcut. Will be deactivated if `use_search_filter` is `True`. - use_filter_search: + use_search_filter: Condition that, if `True`, uses `use_search_filter` in `checkbox`/`select` question while deactivating `use_jk_keys`, allowing for selection via filtering. """ @@ -228,7 +228,7 @@ class Question: validator: str = "" when: str | bool = True use_shortcuts: bool = False - use_filter_search: bool = False + use_search_filter: bool = False @field_validator("var_name") @classmethod @@ -428,7 +428,7 @@ def _validate(answer: str) -> str | Literal[True]: if self.choices: questionary_type = "checkbox" if self.multiselect else "select" - if self.use_filter_search: + if self.use_search_filter: result["use_search_filter"] = True result["use_jk_keys"] = False elif self.use_shortcuts and questionary_type == "select": diff --git a/tests/test_choices.py b/tests/test_choices.py index f592fd8e9..ba35fa8cc 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -42,7 +42,7 @@ type: str help: Select any multiselect: true - use_filter_search: true + use_search_filter: true choices: one: first two: second @@ -134,7 +134,7 @@ type: str help: Select any multiselect: true - use_filter_search: true + use_search_filter: true choices: one: first two: second @@ -156,7 +156,7 @@ select: type: str help: Select one option only - use_filter_search: true + use_search_filter: true default: first choices: one: first From 20d24f8e0de552bb18b94c8f10b69d8e2dec0e89 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Sun, 11 Jan 2026 06:59:21 +0000 Subject: [PATCH 06/20] docs(Question): line-wrap the docstring to max length 88 for consistency Per reviewer request Reviewed-by: sisp Refs: #2446#discussion_r2678786515 --- copier/_user_data.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/copier/_user_data.py b/copier/_user_data.py index 2d3fa00cb..4278799c6 100644 --- a/copier/_user_data.py +++ b/copier/_user_data.py @@ -205,10 +205,14 @@ class Question: boolean values. use_shortcuts: - Condition that, if `True`, will use `use_shortcuts` in `select` question, allowing for selection via automatically numbered shortcut. Will be deactivated if `use_search_filter` is `True`. + Condition that, if `True`, will use `use_shortcuts` in `select` question, + allowing for selection via automatically numbered shortcut. Will be + deactivated if `use_search_filter` is `True`. use_search_filter: - Condition that, if `True`, uses `use_search_filter` in `checkbox`/`select` question while deactivating `use_jk_keys`, allowing for selection via filtering. + Condition that, if `True`, uses `use_search_filter` in `checkbox`/`select` + question while deactivating `use_jk_keys`, allowing for selection via + filtering. """ var_name: str From f8a8cb1ca4ebf5f1971b548a62538dc15e5176d0 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Sun, 11 Jan 2026 07:20:40 +0000 Subject: [PATCH 07/20] test(tests/test_choices.py): remove useless elements Per reviewer suggestion/request Remove development leftovers Reviewed-by: sisp Refs: #2446#discussion_r2678777344 #2446#discussion_r2678779566 --- tests/test_choices.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_choices.py b/tests/test_choices.py index ba35fa8cc..31234e447 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -8,7 +8,6 @@ from plumbum import local from .helpers import ( - BRACKET_ENVOPS, BRACKET_ENVOPS_JSON, COPIER_PATH, SUFFIX_TMPL, @@ -18,9 +17,6 @@ expect_prompt, ) -BLK_START = BRACKET_ENVOPS["block_start_string"] -BLK_END = BRACKET_ENVOPS["block_end_string"] - @pytest.mark.parametrize( "copier_file, input_select, result", @@ -190,7 +186,6 @@ def test_shortcuts( spawn: Spawn, ) -> None: """Test shortcuts.""" - # tui = spawn(COPIER_PATH + ("copy", path, str(tmp_path))) src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) From f734a3527a980c55d3b783595ae96911d2cbeb3c Mon Sep 17 00:00:00 2001 From: RR5555 Date: Sun, 11 Jan 2026 07:47:04 +0000 Subject: [PATCH 08/20] test(tests/test_choices.py): curate the test template Per reviewer suggestion/request Reviewed-by: sisp Refs: #2446#discussion_r2678780560 --- tests/test_choices.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/tests/test_choices.py b/tests/test_choices.py index 31234e447..4f7ef1c0b 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -8,9 +8,7 @@ from plumbum import local from .helpers import ( - BRACKET_ENVOPS_JSON, COPIER_PATH, - SUFFIX_TMPL, Keyboard, Spawn, build_file_tree, @@ -22,9 +20,7 @@ "copier_file, input_select, result", [ ( - f"""\ - _templates_suffix: {SUFFIX_TMPL} - _envops: {BRACKET_ENVOPS_JSON} + """\ select: type: str help: Select one option only @@ -54,9 +50,7 @@ ), ), ( - f"""\ - _templates_suffix: {SUFFIX_TMPL} - _envops: {BRACKET_ENVOPS_JSON} + """\ select: type: str help: Select one option only @@ -84,9 +78,7 @@ ), ), ( - f"""\ - _templates_suffix: {SUFFIX_TMPL} - _envops: {BRACKET_ENVOPS_JSON} + """\ select: type: str help: Select one option only @@ -115,9 +107,7 @@ ), ), ( - f"""\ - _templates_suffix: {SUFFIX_TMPL} - _envops: {BRACKET_ENVOPS_JSON} + """\ select: type: str help: Select one option only @@ -146,9 +136,7 @@ ), ), ( - f"""\ - _templates_suffix: {SUFFIX_TMPL} - _envops: {BRACKET_ENVOPS_JSON} + """\ select: type: str help: Select one option only @@ -193,9 +181,9 @@ def test_shortcuts( build_file_tree( { "copier.yml": copier_file, - "results.txt.tmpl": """\ - select: [[select|tojson]] - checkbox: [[checkbox|tojson]] + "results.txt.jinja": """\ + select: {{select|tojson}} + checkbox: {{checkbox|tojson}} """, } ) From f8c695ed36d0091650e05c6d7d859f628578a935 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Sun, 11 Jan 2026 19:59:41 +0000 Subject: [PATCH 09/20] docs(docs/configuring.md): add docs for `use_shortcuts` & `use_search_filter` Per reviewer suggestion/request Reviewed-by: sisp Refs: #2446#pullrequestreview-3647014001 --- docs/configuring.md | 155 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/docs/configuring.md b/docs/configuring.md index b3488eeae..597ba48c7 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -185,8 +185,163 @@ Supported keys: Some array: "[str, keeps, this, as, a, str]" ``` +- **use_shortcuts**: When set to `true`, numbers the choices while allowing for cursor + selection by pressing the corresponding number. Only works when `multiselect` is + `false`. + + !!! example + + ```yaml title="copier.yml" + language: + type: str + help: Which programming language do you use? + use_shortcuts: true + choices: + - python + - node + - c + - c++ + - rust + - zig + - asm + ``` + + Will result in: + +
+        🎤 Which programming language do you use?
+           (Use shortcuts or arrow keys)
+         » 1) python
+           2) node
+           3) c
+           4) c++
+           5) rust
+           6) zig
+           7) asm
+        
+ + Pressing `5` gives: + +
+        🎤 Which programming language do you use?
+           (Use shortcuts or arrow keys)
+           1) python
+           2) node
+           3) c
+           4) c++
+         » 5) rust
+           6) zig
+           7) asm
+        
+ - **multiselect**: When set to `true`, allows multiple choices. The answer will be a `list[T]` instead of a `T` where `T` is of type `type`. + +- **use_search_filter**: When set to `true`, . Also deactivates the use of `j`/`k` + keys for navigation, as these are captured as prompts for the search filter. + + !!! note + + If `multiselect` is `true`, you cannot use `Space` in the search as this would actually just still select the option. If it is `false`, the `Space` character can be used in the search filter. + + !!! note + + If `use_shortcuts` & `use_search_filter` are both `true`, then only `use_search_filter` is activated. + + !!! example + + ```yaml title="copier.yml" + language: + type: str + help: Which programming language do you use? + use_search_filter: true + choices: + - python + - node + - c + - c++ + - rust + - zig + - asm + - a new language + - a good one + - an average one + - a not so good one + ``` + +
+        🎤 Which programming language do you use?
+           (Use arrow keys, type to filter)
+         » python
+           node
+           c
+           c++
+           rust
+           zig
+           asm
+           a new language
+           a good one
+           an average one
+           a not so good one
+
+        
+ + --- + + + Typing `c`: + +
+        🎤 Which programming language do you use?
+           (Use arrow keys, type to filter)
+         » c
+           c++
+
+
+        / c...
+        
+ + --- + + Typing `an`: + +
+        🎤 Which programming language do you use?
+           (Use arrow keys, type to filter)
+         » a new language
+           an average one
+
+        / an...
+        
+ + --- + + Typing `ago` + +
+        🎤 Which programming language do you use?
+           (Use arrow keys, type to filter)
+         » python
+           node
+           c
+           c++
+           rust
+           zig
+           asm
+           a new language
+           a good one
+           an average one
+           a not so good one
+
+        / ago...
+        
+ + When the filter fails, all options are displayed. + + --- + + You can use `Backspace` to modify the search filter. + - **default**: Leave empty to force the user to answer. Provide a default to save them from typing it if it's quite common. When using `choices`, the default must be the choice _value_, not its _key_, and it must match its _type_. If values are quite From 70e7e427206c7542ce5756a6880f531e10001d0b Mon Sep 17 00:00:00 2001 From: RR5555 Date: Mon, 12 Jan 2026 19:25:29 +0000 Subject: [PATCH 10/20] test(tests/test_choices.py): split tests between `use_shortcuts` & `use_searcg_filter` Per reviewer suggestion/request Reviewed-by: sisp Refs: #2446#discussion_r2678783429 --- tests/test_choices.py | 346 ++++++++++++++++++++++++++++++------------ 1 file changed, 250 insertions(+), 96 deletions(-) diff --git a/tests/test_choices.py b/tests/test_choices.py index 4f7ef1c0b..d03c58257 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -16,10 +16,60 @@ ) +def tui_test_template( + tmp_path_factory: pytest.TempPathFactory, + copier_file: str, + tui_io: tuple[dict], + result_template: str, + result: str, + spawn: Spawn, +) -> None: + """Template for general test of tui. + + Use: + > { + > "expect_prompt": { + > "name":, + > "type":, + > "help":, + > }, + > "expect_exact": (), + > "send":(), + > }, + """ + + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + with local.cwd(src): + build_file_tree( + { + "copier.yml": copier_file, + "results.txt.jinja": result_template, + } + ) + + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + + for _tui in tui_io: + _expect_prompt = _tui["expect_prompt"] + expect_prompt( + tui, + _expect_prompt["name"], + _expect_prompt["type"], + help=_expect_prompt["help"], + ) + deque(map(tui.expect_exact, _tui["expect_exact"])) + deque(map(tui.send, _tui["send"])) + + tui.expect_exact(pexpect.EOF) + assert (dst / "results.txt").read_text() == result + + @pytest.mark.parametrize( - "copier_file, input_select, result", + "copier_file, tui_io, result_template, result", [ ( + # copier_file """\ select: type: str @@ -30,188 +80,292 @@ one: first two: second three: third - checkbox: - type: str - help: Select any - multiselect: true - use_search_filter: true - choices: - one: first - two: second - three: third - four: fourth """, - "3", + # tui_io + ( + { + "expect_prompt": { + "name": "select", + "type": "str", + "help": "Select one option only", + }, + "expect_exact": ("one", "two", "three"), + "send": ("3", Keyboard.Enter), + }, + ), + # result_template + """\ + select: {{select|tojson}} + """, + # result dedent( """\ select: "third" - checkbox: ["third"] """, ), ), ( + # copier_file """\ select: type: str help: Select one option only + use_shortcuts: false default: first choices: one: first two: second three: third - checkbox: - type: str - help: Select any - multiselect: true - choices: - one: first - two: second - three: third - four: fourth """, - "3", + # tui_io + ( + { + "expect_prompt": { + "name": "select", + "type": "str", + "help": "Select one option only", + }, + "expect_exact": ("one", "two", "three"), + "send": ("3", Keyboard.Enter), + }, + ), + # result_template + """\ + select: {{select|tojson}} + """, + # result dedent( """\ select: "first" - checkbox: ["first"] - """ + """, ), ), ( + # copier_file """\ select: type: str help: Select one option only use_shortcuts: true + use_search_filter: true default: first choices: one: first two: second three: third - checkbox: + """, + # tui_io + ( + { + "expect_prompt": { + "name": "select", + "type": "str", + "help": "Select one option only", + }, + "expect_exact": ("one", "two", "three"), + "send": ("3", Keyboard.Enter), + }, + ), + # result_template + """\ + select: {{select|tojson}} + """, + # result + dedent( + """\ + select: "first" + """, + ), + ), + ], +) +def test_use_shortcuts( + tmp_path_factory: pytest.TempPathFactory, + copier_file: str, + tui_io: tuple[dict], + result_template: str, + result: str, + spawn: Spawn, +) -> None: + """Test `use_shortcuts`.""" + tui_test_template( + tmp_path_factory=tmp_path_factory, + copier_file=copier_file, + tui_io=tui_io, + result_template=result_template, + result=result, + spawn=spawn, + ) + + +@pytest.mark.parametrize( + "copier_file, tui_io, result_template, result", + [ + ( + # copier_file + """\ + select: type: str - help: Select any - multiselect: true + help: Select one option only + use_shortcuts: true + use_search_filter: true + default: first choices: one: first two: second three: third - four: fourth + four: forth """, - "3", + # tui_io + ( + { + "expect_prompt": { + "name": "select", + "type": "str", + "help": "Select one option only", + }, + "expect_exact": ("one", "two", "three", "four"), + "send": ("3", Keyboard.Enter), + }, + ), + # result_template + """\ + select: {{select|tojson}} + """, + # result dedent( """\ - select: "third" - checkbox: ["first"] - """ + select: "first" + """, ), ), ( + # copier_file """\ select: type: str help: Select one option only + use_search_filter: true default: first choices: one: first two: second three: third + four: forth + """, + # tui_io + ( + { + "expect_prompt": { + "name": "select", + "type": "str", + "help": "Select one option only", + }, + "expect_exact": ("one", "two", "three", "four"), + "send": ("tw", Keyboard.Enter), + }, + ), + # result_template + """\ + select: {{select|tojson}} + """, + # result + dedent( + """\ + select: "second" + """, + ), + ), + ( + # copier_file + """\ checkbox: type: str - help: Select any + help: Select any option multiselect: true use_search_filter: true choices: one: first two: second three: third - four: fourth + four: forth + """, + # tui_io + ( + { + "expect_prompt": { + "name": "checkbox", + "type": "str", + "help": "Select any option", + }, + "expect_exact": ("one", "two", "three", "four"), + "send": ("tw ", Keyboard.Enter), + }, + ), + # result_template + """\ + checkbox: {{checkbox|tojson}} """, - "3", + # result dedent( """\ - select: "first" - checkbox: ["third"] - """ + checkbox: ["second"] + """, ), ), ( + # copier_file """\ - select: - type: str - help: Select one option only - use_search_filter: true - default: first - choices: - one: first - two: second - three: third checkbox: type: str - help: Select any + help: Select any option multiselect: true + use_search_filter: true choices: one: first two: second three: third - four: fourth + four: forth + """, + # tui_io + ( + { + "expect_prompt": { + "name": "checkbox", + "type": "str", + "help": "Select any option", + }, + "expect_exact": ("one", "two", "three", "four"), + "send": ("tr ", Keyboard.Enter), + }, + ), + # result_template + """\ + checkbox: {{checkbox|tojson}} """, - "tw", + # result dedent( """\ - select: "second" checkbox: ["first"] - """ + """, ), ), ], ) -def test_shortcuts( +def test_use_search_filter( tmp_path_factory: pytest.TempPathFactory, copier_file: str, - input_select: str, + tui_io: tuple[dict], + result_template: str, result: str, spawn: Spawn, ) -> None: - """Test shortcuts.""" - - src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) - - with local.cwd(src): - build_file_tree( - { - "copier.yml": copier_file, - "results.txt.jinja": """\ - select: {{select|tojson}} - checkbox: {{checkbox|tojson}} - """, - } - ) - - tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) - - expect_prompt(tui, "select", "str", help="Select one option only") - deque( - map( - tui.expect_exact, - [ - "one", - "two", - "three", - ], - ) - ) - tui.send(input_select) - tui.send(Keyboard.Enter) - - expect_prompt(tui, "checkbox", "str", help="Select any") - deque( - map( - tui.expect_exact, - ["one", "two", "three", "four"], - ) + """Test `use_search_filter`.""" + tui_test_template( + tmp_path_factory=tmp_path_factory, + copier_file=copier_file, + tui_io=tui_io, + result_template=result_template, + result=result, + spawn=spawn, ) - tui.send("th ") - tui.send(Keyboard.Enter) - tui.expect_exact(pexpect.EOF) - assert (dst / "results.txt").read_text() == result From 4ba04f759a761ba2fc030bb30f26fd2cdc3d2efa Mon Sep 17 00:00:00 2001 From: root Date: Mon, 12 Jan 2026 21:04:02 +0000 Subject: [PATCH 11/20] test(tests/test_choices): fix type hints for compliance with `test_types` change `dict` to `dict[str, Any]` --- tests/test_choices.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_choices.py b/tests/test_choices.py index d03c58257..eeac83026 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -2,6 +2,7 @@ from collections import deque from textwrap import dedent +from typing import Any import pexpect import pytest @@ -19,7 +20,7 @@ def tui_test_template( tmp_path_factory: pytest.TempPathFactory, copier_file: str, - tui_io: tuple[dict], + tui_io: tuple[dict[str, Any]], result_template: str, result: str, spawn: Spawn, @@ -182,7 +183,7 @@ def tui_test_template( def test_use_shortcuts( tmp_path_factory: pytest.TempPathFactory, copier_file: str, - tui_io: tuple[dict], + tui_io: tuple[dict[str, Any]], result_template: str, result: str, spawn: Spawn, @@ -355,7 +356,7 @@ def test_use_shortcuts( def test_use_search_filter( tmp_path_factory: pytest.TempPathFactory, copier_file: str, - tui_io: tuple[dict], + tui_io: tuple[dict[str, Any]], result_template: str, result: str, spawn: Spawn, From 5b3e9020e23d46a5b7426d8873ae0afb5b139534 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Mon, 16 Mar 2026 23:20:33 +0000 Subject: [PATCH 12/20] fix(Question): change from superseeding to `ValidationError` for mutual exclusiveness Per reviewer request. Before: `use_search_filter`>`use_shortcuts` After: ValidationError Before: `multiselect`>`use_shortcuts` After: ValidationError Refs: #2446#pullrequestreview-3687996014 --- copier/_user_data.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/copier/_user_data.py b/copier/_user_data.py index eb6e6f651..a7508b8e6 100644 --- a/copier/_user_data.py +++ b/copier/_user_data.py @@ -19,8 +19,9 @@ from jinja2 import StrictUndefined, UndefinedError from jinja2.sandbox import SandboxedEnvironment from prompt_toolkit.lexers import PygmentsLexer -from pydantic import ConfigDict, Field, field_validator +from pydantic import ConfigDict, Field, ValidationError, field_validator from pydantic.dataclasses import dataclass +from pydantic_core import PydanticCustomError from pydantic_core.core_schema import ValidationInfo from pygments.lexers.data import JsonLexer, YamlLexer from questionary.prompts.common import Choice @@ -433,7 +434,36 @@ def _validate(answer: str) -> str | Literal[True]: if self.use_search_filter: result["use_search_filter"] = True result["use_jk_keys"] = False - elif self.use_shortcuts and questionary_type == "select": + if self.use_shortcuts: + if self.multiselect: + raise ValidationError.from_exception_data( + title=f"`{self.var_name}`", + line_errors=[ + { + "type": PydanticCustomError( + '"`use_shortcuts` & `multiselect` are mutually exclusive"', + "Use either `use_shortcuts: true` or `multiselect: true`", + ), + "input": {"use_shortcuts": True, "multiselect": True}, + } + ], + ) + if self.use_search_filter: + raise ValidationError.from_exception_data( + title=f"`{self.var_name}`", + line_errors=[ + { + "type": PydanticCustomError( + '"`use_shortcuts` & `use_search_filter` are mutually exclusive"', + "Use either `use_shortcuts: true` or `use_search_filter: true`", + ), + "input": { + "use_shortcuts": True, + "use_search_filter": True, + }, + } + ], + ) result["use_shortcuts"] = True choices = self._formatted_choices From 928e117e554ae3ec35e96f1bb59ed2cd12350d6a Mon Sep 17 00:00:00 2001 From: RR5555 Date: Mon, 16 Mar 2026 23:29:26 +0000 Subject: [PATCH 13/20] docs(Question): improve & adapt docstring to mutual exclusiveness Per reviewer request. Refs: #2446#pullrequestreview-3687996014 --- copier/_user_data.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/copier/_user_data.py b/copier/_user_data.py index a7508b8e6..8d676a57e 100644 --- a/copier/_user_data.py +++ b/copier/_user_data.py @@ -204,14 +204,15 @@ class Question: boolean values. use_shortcuts: - Condition that, if `True`, will use `use_shortcuts` in `select` question, - allowing for selection via automatically numbered shortcut. Will be - deactivated if `use_search_filter` is `True`. + Condition that, if `True`, allows selecting choice question items via + number shortcuts. Mutually exclusive with `multiselect` and + `use_search_filter`. use_search_filter: - Condition that, if `True`, uses `use_search_filter` in `checkbox`/`select` - question while deactivating `use_jk_keys`, allowing for selection via - filtering. + Condition that, if `True`, enables filtering choice question items by + typing a search string. Disables j/k navigation, as "j" and "k" can be part + of a prefix and therefore cannot be used for navigation. Mutually exclusive + with `use_shortcuts`. """ var_name: str From 215cd0c9dfa9679b2243ce0559b9bd8e4cca37d6 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Mon, 16 Mar 2026 23:33:08 +0000 Subject: [PATCH 14/20] test(tests/test_choices.py): break down fixtures into clearer tests; add ValidationError tests Per reviewer request. Refs: #2446#discussion_r2718709082 --- tests/test_choices.py | 596 ++++++++++++++++++------------------------ 1 file changed, 257 insertions(+), 339 deletions(-) diff --git a/tests/test_choices.py b/tests/test_choices.py index eeac83026..d27dd41c0 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -1,372 +1,290 @@ from __future__ import annotations -from collections import deque -from textwrap import dedent -from typing import Any - import pexpect import pytest -from plumbum import local -from .helpers import ( - COPIER_PATH, - Keyboard, - Spawn, - build_file_tree, - expect_prompt, -) +from .helpers import COPIER_PATH, Keyboard, Spawn, build_file_tree, expect_prompt -def tui_test_template( - tmp_path_factory: pytest.TempPathFactory, - copier_file: str, - tui_io: tuple[dict[str, Any]], - result_template: str, - result: str, - spawn: Spawn, +def test_shortcuts_disabled_by_default( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn ) -> None: - """Template for general test of tui. + """Shortcuts are disabled by default, so numbers don't select choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("3") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "first" - Use: - > { - > "expect_prompt": { - > "name":, - > "type":, - > "help":, - > }, - > "expect_exact": (), - > "send":(), - > }, - """ +def test_shortcuts_disabled( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When shortcuts are disabled, numbers don't select choices.""" src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + use_shortcuts: false + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("3") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "first" - with local.cwd(src): - build_file_tree( - { - "copier.yml": copier_file, - "results.txt.jinja": result_template, - } - ) +def test_shortcuts_enabled( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When shortcuts are enabled, numbers select choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + use_shortcuts: true + """ + ), + src / "result.jinja": "{{ select }}", + } + ) tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("3") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "third" - for _tui in tui_io: - _expect_prompt = _tui["expect_prompt"] - expect_prompt( - tui, - _expect_prompt["name"], - _expect_prompt["type"], - help=_expect_prompt["help"], - ) - deque(map(tui.expect_exact, _tui["expect_exact"])) - deque(map(tui.send, _tui["send"])) +def test_multiselect_with_shortcuts_not_supported( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When shortcuts and multiselect are both enabled, a ValidationError is raised.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + multiselect: true + choices: + one: first + two: second + three: third + use_shortcuts: true + """ + ) + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) tui.expect_exact(pexpect.EOF) - assert (dst / "results.txt").read_text() == result + assert tui.exitstatus != 0 + assert tui.proc.returncode != 0 + assert ( + "pydantic_core._pydantic_core.ValidationError: 1 validation error" + in str(tui.before).split("\n")[-3] + ) -@pytest.mark.parametrize( - "copier_file, tui_io, result_template, result", - [ - ( - # copier_file - """\ - select: - type: str - help: Select one option only - use_shortcuts: true - default: first - choices: - one: first - two: second - three: third - """, - # tui_io - ( - { - "expect_prompt": { - "name": "select", - "type": "str", - "help": "Select one option only", - }, - "expect_exact": ("one", "two", "three"), - "send": ("3", Keyboard.Enter), - }, - ), - # result_template - """\ - select: {{select|tojson}} - """, - # result - dedent( - """\ - select: "third" - """, - ), - ), - ( - # copier_file - """\ - select: - type: str - help: Select one option only - use_shortcuts: false - default: first - choices: - one: first - two: second - three: third - """, - # tui_io - ( - { - "expect_prompt": { - "name": "select", - "type": "str", - "help": "Select one option only", - }, - "expect_exact": ("one", "two", "three"), - "send": ("3", Keyboard.Enter), - }, - ), - # result_template - """\ - select: {{select|tojson}} - """, - # result - dedent( - """\ - select: "first" - """, - ), - ), - ( - # copier_file - """\ - select: - type: str - help: Select one option only - use_shortcuts: true - use_search_filter: true - default: first - choices: - one: first - two: second - three: third - """, - # tui_io - ( - { - "expect_prompt": { - "name": "select", - "type": "str", - "help": "Select one option only", - }, - "expect_exact": ("one", "two", "three"), - "send": ("3", Keyboard.Enter), - }, - ), - # result_template - """\ - select: {{select|tojson}} - """, - # result - dedent( +def test_search_filter_disabled_by_default( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """Search filter is disabled by default, so typing doesn't narrow choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( """\ - select: "first" - """, + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + """ ), - ), - ], -) -def test_use_shortcuts( - tmp_path_factory: pytest.TempPathFactory, - copier_file: str, - tui_io: tuple[dict[str, Any]], - result_template: str, - result: str, - spawn: Spawn, -) -> None: - """Test `use_shortcuts`.""" - tui_test_template( - tmp_path_factory=tmp_path_factory, - copier_file=copier_file, - tui_io=tui_io, - result_template=result_template, - result=result, - spawn=spawn, + src / "result.jinja": "{{ select }}", + } ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("tw") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "first" -@pytest.mark.parametrize( - "copier_file, tui_io, result_template, result", - [ - ( - # copier_file - """\ - select: - type: str - help: Select one option only - use_shortcuts: true - use_search_filter: true - default: first - choices: - one: first - two: second - three: third - four: forth - """, - # tui_io - ( - { - "expect_prompt": { - "name": "select", - "type": "str", - "help": "Select one option only", - }, - "expect_exact": ("one", "two", "three", "four"), - "send": ("3", Keyboard.Enter), - }, - ), - # result_template - """\ - select: {{select|tojson}} - """, - # result - dedent( +def test_search_filter_disabled( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When search filter is disabled, typing doesn't narrow choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( """\ - select: "first" - """, + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + use_search_filter: false + """ ), - ), - ( - # copier_file - """\ - select: - type: str - help: Select one option only - use_search_filter: true - default: first - choices: - one: first - two: second - three: third - four: forth - """, - # tui_io - ( - { - "expect_prompt": { - "name": "select", - "type": "str", - "help": "Select one option only", - }, - "expect_exact": ("one", "two", "three", "four"), - "send": ("tw", Keyboard.Enter), - }, - ), - # result_template - """\ - select: {{select|tojson}} - """, - # result - dedent( + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("tw") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "first" + + +def test_search_filter_enabled( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When search filter is enabled, typing narrows choices to matching options.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( """\ - select: "second" - """, - ), - ), - ( - # copier_file - """\ - checkbox: - type: str - help: Select any option - multiselect: true - use_search_filter: true - choices: - one: first - two: second - three: third - four: forth - """, - # tui_io - ( - { - "expect_prompt": { - "name": "checkbox", - "type": "str", - "help": "Select any option", - }, - "expect_exact": ("one", "two", "three", "four"), - "send": ("tw ", Keyboard.Enter), - }, + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + use_search_filter: true + """ ), - # result_template - """\ - checkbox: {{checkbox|tojson}} - """, - # result - dedent( + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("tw") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "second" + + +def test_search_filter_and_shortcut_not_supported( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """ + When search filter and shortcuts are enabled, a ValidationError is raised. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( """\ - checkbox: ["second"] - """, + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + four: forth + use_search_filter: true + use_shortcuts: true + """ ), - ), - ( - # copier_file - """\ - checkbox: - type: str - help: Select any option - multiselect: true - use_search_filter: true - choices: - one: first - two: second - three: third - four: forth - """, - # tui_io - ( - { - "expect_prompt": { - "name": "checkbox", - "type": "str", - "help": "Select any option", - }, - "expect_exact": ("one", "two", "three", "four"), - "send": ("tr ", Keyboard.Enter), - }, - ), - # result_template - """\ - checkbox: {{checkbox|tojson}} - """, - # result - dedent( + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + tui.expect_exact(pexpect.EOF) + assert tui.exitstatus != 0 + assert tui.proc.returncode != 0 + assert ( + "pydantic_core._pydantic_core.ValidationError: 1 validation error" + in str(tui.before).split("\n")[-3] + ) + + +def test_multiselect_with_search_filter( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """Search filter works with multiselect prompts.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( """\ - checkbox: ["first"] - """, + checkbox: + type: str + help: Select any option + multiselect: true + choices: + one: first + two: second + three: third + use_search_filter: true + """ ), - ), - ], -) -def test_use_search_filter( - tmp_path_factory: pytest.TempPathFactory, - copier_file: str, - tui_io: tuple[dict[str, Any]], - result_template: str, - result: str, - spawn: Spawn, -) -> None: - """Test `use_search_filter`.""" - tui_test_template( - tmp_path_factory=tmp_path_factory, - copier_file=copier_file, - tui_io=tui_io, - result_template=result_template, - result=result, - spawn=spawn, + src / "result.jinja": "{{ checkbox }}", + } ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "checkbox", "str", help="Select any option") + tui.send("tw ") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "['second']" From 04cd33ddb1c176b9022f055d6db740257f2df39a Mon Sep 17 00:00:00 2001 From: RR5555 Date: Mon, 16 Mar 2026 23:36:21 +0000 Subject: [PATCH 15/20] docs(docs/configuring.md): improve & simplify the docs relative to `use_shortcuts` & `use_search_filter` Per reviewer request. Refs: #2446#pullrequestreview-3687996014 --- docs/configuring.md | 70 ++++++++------------------------------------- 1 file changed, 12 insertions(+), 58 deletions(-) diff --git a/docs/configuring.md b/docs/configuring.md index c3541d9cb..f97aa07fa 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -185,9 +185,8 @@ Supported keys: Some array: "[str, keeps, this, as, a, str]" ``` -- **use_shortcuts**: When set to `true`, numbers the choices while allowing for cursor - selection by pressing the corresponding number. Only works when `multiselect` is - `false`. +- **use_shortcuts**: When set to `true`, allows selecting choice question items via + number shortcuts. Mutually exclusive with `multiselect` and `use_search_filter`. !!! example @@ -237,16 +236,14 @@ Supported keys: - **multiselect**: When set to `true`, allows multiple choices. The answer will be a `list[T]` instead of a `T` where `T` is of type `type`. -- **use_search_filter**: When set to `true`, . Also deactivates the use of `j`/`k` - keys for navigation, as these are captured as prompts for the search filter. +- **use_search_filter**: When set to `true`, enables filtering choice question items + by typing a search string. Also deactivates the use of `j`/`k` keys for navigation, + as these are captured as prompts for the search filter. Mutually exclusive with + `use_shortcuts`. !!! note - If `multiselect` is `true`, you cannot use `Space` in the search as this would actually just still select the option. If it is `false`, the `Space` character can be used in the search filter. - - !!! note - - If `use_shortcuts` & `use_search_filter` are both `true`, then only `use_search_filter` is activated. + If `multiselect` is `true`, you cannot use ++space++ in the search, as this would only select the choice item. If it is `false`, ++space++ can be used. !!! example @@ -263,10 +260,6 @@ Supported keys: - rust - zig - asm - - a new language - - a good one - - an average one - - a not so good one ```
@@ -279,68 +272,29 @@ Supported keys:
            rust
            zig
            asm
-           a new language
-           a good one
-           an average one
-           a not so good one
-
         
--- - - Typing `c`: + Typing `o`:
         🎤 Which programming language do you use?
            (Use arrow keys, type to filter)
-         » c
-           c++
+         » python
+           node
 
 
-        / c...
+        / o...
         
--- - Typing `an`: - -
-        🎤 Which programming language do you use?
-           (Use arrow keys, type to filter)
-         » a new language
-           an average one
-
-        / an...
-        
- - --- - - Typing `ago` - -
-        🎤 Which programming language do you use?
-           (Use arrow keys, type to filter)
-         » python
-           node
-           c
-           c++
-           rust
-           zig
-           asm
-           a new language
-           a good one
-           an average one
-           a not so good one
-
-        / ago...
-        
- When the filter fails, all options are displayed. --- - You can use `Backspace` to modify the search filter. + You can use ++backspace++ to modify the search filter. - **default**: Leave empty to force the user to answer. Provide a default to save them from typing it if it's quite common. When using `choices`, the default must be the From db9794e3311d711ce843180fe7dc51279d209b91 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Mon, 16 Mar 2026 23:39:13 +0000 Subject: [PATCH 16/20] feat(mkdocs.yml): enable `pymdownx.keys` for pretty keyboard keys Per reviewer request/suggestion. Refs: #2446#pullrequestreview-3687996014 --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 7494dea79..bacbe1148 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ markdown_extensions: class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.emoji + - pymdownx.keys - pymdownx.magiclink - toc: permalink: true From 5d0c9c78adb0d71220bb7de70a9c503acb6db6b0 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Sun, 3 May 2026 11:20:21 +0000 Subject: [PATCH 17/20] refactor(Question): move `use_shortcuts` validation to a Pydantic Model Validator Per reviewer request. Refs: #2446#discussion_r2959476896 --- copier/_user_data.py | 46 ++++++++++++++----------------------------- tests/test_choices.py | 22 +++++++++++++-------- 2 files changed, 29 insertions(+), 39 deletions(-) diff --git a/copier/_user_data.py b/copier/_user_data.py index 8d676a57e..8690daf6b 100644 --- a/copier/_user_data.py +++ b/copier/_user_data.py @@ -19,12 +19,12 @@ from jinja2 import StrictUndefined, UndefinedError from jinja2.sandbox import SandboxedEnvironment from prompt_toolkit.lexers import PygmentsLexer -from pydantic import ConfigDict, Field, ValidationError, field_validator +from pydantic import ConfigDict, Field, field_validator, model_validator from pydantic.dataclasses import dataclass -from pydantic_core import PydanticCustomError from pydantic_core.core_schema import ValidationInfo from pygments.lexers.data import JsonLexer, YamlLexer from questionary.prompts.common import Choice +from typing_extensions import Self from copier._jinja_ext import UnsetError from copier._settings import SettingsModel @@ -258,6 +258,19 @@ def _check_secret_question_default_value( raise ValueError("Secret question requires a default value") return v + @model_validator(mode="after") + def _check_no_multiselect_or_search_filter_with_use_shortcuts(self) -> Self: + if self.use_shortcuts: + if self.multiselect: + raise ValueError( + f"[Question Name: `{self.var_name}`]\n `use_shortcuts` & `multiselect` are mutually exclusive\n Use either `use_shortcuts: true` or `multiselect: true`\n " + ) + if self.use_search_filter: + raise ValueError( + f"[Question Name: `{self.var_name}`]\n `use_shortcuts` & `use_search_filter` are mutually exclusive\n Use either `use_shortcuts: true` or `use_search_filter: true`\n " + ) + return self + def cast_answer(self, answer: Any) -> Any: """Cast answer to expected type.""" type_name = self.get_type_name() @@ -436,35 +449,6 @@ def _validate(answer: str) -> str | Literal[True]: result["use_search_filter"] = True result["use_jk_keys"] = False if self.use_shortcuts: - if self.multiselect: - raise ValidationError.from_exception_data( - title=f"`{self.var_name}`", - line_errors=[ - { - "type": PydanticCustomError( - '"`use_shortcuts` & `multiselect` are mutually exclusive"', - "Use either `use_shortcuts: true` or `multiselect: true`", - ), - "input": {"use_shortcuts": True, "multiselect": True}, - } - ], - ) - if self.use_search_filter: - raise ValidationError.from_exception_data( - title=f"`{self.var_name}`", - line_errors=[ - { - "type": PydanticCustomError( - '"`use_shortcuts` & `use_search_filter` are mutually exclusive"', - "Use either `use_shortcuts: true` or `use_search_filter: true`", - ), - "input": { - "use_shortcuts": True, - "use_search_filter": True, - }, - } - ], - ) result["use_shortcuts"] = True choices = self._formatted_choices diff --git a/tests/test_choices.py b/tests/test_choices.py index d27dd41c0..597ff698f 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -124,10 +124,13 @@ def test_multiselect_with_shortcuts_not_supported( tui.expect_exact(pexpect.EOF) assert tui.exitstatus != 0 assert tui.proc.returncode != 0 - assert ( - "pydantic_core._pydantic_core.ValidationError: 1 validation error" - in str(tui.before).split("\n")[-3] - ) + assert [ + "pydantic_core._pydantic_core.ValidationError: 1 validation error for Question", + " Value error, [Question Name: `select`]", + " `use_shortcuts` & `multiselect` are mutually exclusive", + " Use either `use_shortcuts: true` or `multiselect: true`", + " [type=value_error, input_value=ArgsKwargs((), {'answers'... 'use_shortcuts': True}), input_type=ArgsKwargs]", + ] == str(tui.before).split("\n")[-7:-2] def test_search_filter_disabled_by_default( @@ -253,10 +256,13 @@ def test_search_filter_and_shortcut_not_supported( tui.expect_exact(pexpect.EOF) assert tui.exitstatus != 0 assert tui.proc.returncode != 0 - assert ( - "pydantic_core._pydantic_core.ValidationError: 1 validation error" - in str(tui.before).split("\n")[-3] - ) + assert [ + "pydantic_core._pydantic_core.ValidationError: 1 validation error for Question", + " Value error, [Question Name: `select`]", + " `use_shortcuts` & `use_search_filter` are mutually exclusive", + " Use either `use_shortcuts: true` or `use_search_filter: true`", + " [type=value_error, input_value=ArgsKwargs((), {'answers'... 'use_shortcuts': True}), input_type=ArgsKwargs]", + ] == str(tui.before).split("\n")[-7:-2] def test_multiselect_with_search_filter( From ddddbeadc7e95365509ae08dfcfd06954a15ce3c Mon Sep 17 00:00:00 2001 From: RR5555 Date: Sun, 3 May 2026 11:23:47 +0000 Subject: [PATCH 18/20] docs(docs/configuring.md): switch "Supported keys" item order Per reviewer request. Refs: #2446#discussion_r2959525263 --- docs/configuring.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuring.md b/docs/configuring.md index 87206e818..c622ae796 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -190,6 +190,9 @@ Supported keys: Some array: "[str, keeps, this, as, a, str]" ``` +- **multiselect**: When set to `true`, allows multiple choices. The answer will be a + `list[T]` instead of a `T` where `T` is of type `type`. + - **use_shortcuts**: When set to `true`, allows selecting choice question items via number shortcuts. Mutually exclusive with `multiselect` and `use_search_filter`. @@ -238,9 +241,6 @@ Supported keys: 7) asm -- **multiselect**: When set to `true`, allows multiple choices. The answer will be a - `list[T]` instead of a `T` where `T` is of type `type`. - - **use_search_filter**: When set to `true`, enables filtering choice question items by typing a search string. Also deactivates the use of `j`/`k` keys for navigation, as these are captured as prompts for the search filter. Mutually exclusive with From 38a3954e95475fa040099bf21eed7dd463449614 Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 5 May 2026 06:28:07 +0000 Subject: [PATCH 19/20] docs(docs/configuring.md): disable `rumdl` for `pre` html tag blocks pre-commit hook `rumdl` run by `autofix-ci` would break the output examples contained in the `pre` tags --- docs/configuring.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/configuring.md b/docs/configuring.md index c622ae796..4f34a86d3 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -267,6 +267,7 @@ Supported keys: - asm ``` +
         🎤 Which programming language do you use?
            (Use arrow keys, type to filter)
@@ -278,11 +279,13 @@ Supported keys:
            zig
            asm
         
+ --- Typing `o`: +
         🎤 Which programming language do you use?
            (Use arrow keys, type to filter)
@@ -292,6 +295,7 @@ Supported keys:
 
         / o...
         
+ --- From aa1c05a21ed839e927c802b9612165abd5e8ff2e Mon Sep 17 00:00:00 2001 From: RR5555 Date: Tue, 5 May 2026 06:33:42 +0000 Subject: [PATCH 20/20] docs(docs/configuring.md): disable `rumdl` for `pre` html tag blocks Follow-up on the previous commit where two additional `pre` blocks were missed. --- docs/configuring.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/configuring.md b/docs/configuring.md index 4f34a86d3..519853030 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -215,6 +215,7 @@ Supported keys: Will result in: +
         🎤 Which programming language do you use?
            (Use shortcuts or arrow keys)
@@ -226,9 +227,11 @@ Supported keys:
            6) zig
            7) asm
         
+ Pressing `5` gives: +
         🎤 Which programming language do you use?
            (Use shortcuts or arrow keys)
@@ -240,6 +243,7 @@ Supported keys:
            6) zig
            7) asm
         
+ - **use_search_filter**: When set to `true`, enables filtering choice question items by typing a search string. Also deactivates the use of `j`/`k` keys for navigation,