Skip to content

Commit 1e16b65

Browse files
sseemayersispautofix-ci[bot]
authored
feat: add support for prompting filesystem paths (#2210)
Co-authored-by: Sigurd Spieckermann <2206639+sisp@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 4e092c1 commit 1e16b65

5 files changed

Lines changed: 148 additions & 4 deletions

File tree

copier/_user_data.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,9 @@ def _validate(answer: str) -> str | Literal[True]:
427427
result["multiline"] = self.get_multiline()
428428
if placeholder := self.get_placeholder():
429429
result["placeholder"] = placeholder
430-
if questionary_type in {"input", "checkbox", "password"}:
430+
if type_name == "path":
431+
questionary_type = "path"
432+
if questionary_type in {"input", "checkbox", "password", "path"}:
431433
result["validate"] = _validate
432434
result.update({"type": questionary_type})
433435
return result
@@ -593,4 +595,5 @@ def load_answersfile_data(
593595
"json": json.loads,
594596
"str": cast_to_str,
595597
"yaml": parse_yaml_string,
598+
"path": str,
596599
}

docs/configuring.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ to ask users for data. To use it, the value must be a dict.
7373
Supported keys:
7474

7575
- **type**: User input must match this type. Options are: `bool`, `float`, `int`,
76-
`json`, `str`, `yaml` (default).
76+
`json`, `path`, `str`, `yaml` (default).
7777
- **help**: Additional text to help the user know what's this question for.
7878
- **choices**: To restrict possible values.
7979

tests/helpers.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from enum import Enum
1010
from hashlib import sha1
1111
from pathlib import Path
12-
from typing import Any, Protocol
12+
from typing import TYPE_CHECKING, Any, Protocol
1313

1414
from pexpect.popen_spawn import PopenSpawn
1515
from plumbum import local
@@ -22,6 +22,9 @@
2222
import copier
2323
from copier._types import StrOrPath
2424

25+
if TYPE_CHECKING:
26+
from pexpect.spawnbase import SpawnBase
27+
2528
PROJECT_TEMPLATE = Path(__file__).parent / "demo"
2629

2730
DATA = {
@@ -73,6 +76,7 @@ def __call__(self, cmd: tuple[str, ...], *, timeout: int | None) -> PopenSpawn:
7376

7477
class Keyboard(str, Enum):
7578
ControlH = REVERSE_ANSI_SEQUENCES[Keys.ControlH]
79+
ControlI = REVERSE_ANSI_SEQUENCES[Keys.ControlI]
7680
ControlC = REVERSE_ANSI_SEQUENCES[Keys.ControlC]
7781
Enter = "\r"
7882
Esc = REVERSE_ANSI_SEQUENCES[Keys.Escape]
@@ -89,6 +93,7 @@ class Keyboard(str, Enum):
8993
# further explanations
9094
Alt = Esc
9195
Backspace = ControlH
96+
Tab = ControlI
9297

9398

9499
def render(tmp_path: Path, **kwargs: Any) -> None:
@@ -133,7 +138,10 @@ def build_file_tree(
133138

134139

135140
def expect_prompt(
136-
tui: PopenSpawn, name: str, expected_type: str, help: str | None = None
141+
tui: SpawnBase,
142+
name: str,
143+
expected_type: str,
144+
help: str | None = None,
137145
) -> None:
138146
"""Check that we get a prompt in the standard form"""
139147
if help:

tests/test_copy.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,73 @@ def test_value_with_forward_slash(tmp_path_factory: pytest.TempPathFactory) -> N
604604
"yellow",
605605
does_not_raise(),
606606
),
607+
(
608+
{"type": "path"},
609+
"/etc/hostname",
610+
does_not_raise(),
611+
),
612+
(
613+
{"type": "path"},
614+
"/this/does/not/exist",
615+
does_not_raise(),
616+
),
617+
(
618+
{
619+
"type": "path",
620+
"choices": {
621+
"one": None,
622+
"two": "/etc/hostname",
623+
"three": "/does/not/exist",
624+
},
625+
},
626+
"/etc/hostname",
627+
does_not_raise(),
628+
),
629+
(
630+
{
631+
"type": "path",
632+
"choices": {
633+
"one": None,
634+
"two": "/etc/hostname",
635+
"three": "/does/not/exist",
636+
},
637+
},
638+
"/does/not/exist",
639+
does_not_raise(),
640+
),
641+
(
642+
{
643+
"type": "path",
644+
"choices": {
645+
"one": None,
646+
"two": "/etc/hostname",
647+
"three": "/does/not/exist",
648+
},
649+
},
650+
"/another/path/that/does/not/exist",
651+
pytest.raises(ValueError),
652+
),
653+
(
654+
{"type": "path", "validator": ""},
655+
"/this/does/not/exist",
656+
does_not_raise(),
657+
),
658+
(
659+
{
660+
"type": "path",
661+
"validator": "[% if q | length < 1 or q[0] != '/' %]must be absolute path[% endif %]",
662+
},
663+
"/this/does/not/exist",
664+
does_not_raise(),
665+
),
666+
(
667+
{
668+
"type": "path",
669+
"validator": "[% if q | length < 1 or q[0] != '/' %]must be absolute path[% endif %]",
670+
},
671+
"./local/path",
672+
pytest.raises(ValueError),
673+
),
607674
(
608675
{"type": "yaml", "choices": {"one": None, "two": 2, "three": "null"}},
609676
"one",
@@ -910,6 +977,10 @@ def test_required_choice_question_without_data(
910977
({"type": "json", "default": []}, does_not_raise()),
911978
({"type": "json", "default": "null"}, does_not_raise()),
912979
({"type": "json", "default": None}, does_not_raise()),
980+
({"type": "path", "default": None}, pytest.raises(TypeError)),
981+
({"type": "path", "default": ""}, does_not_raise()),
982+
({"type": "path", "default": "/etc/hostname"}, does_not_raise()),
983+
({"type": "path", "default": "/does/not/exist"}, does_not_raise()),
913984
({"type": "yaml", "default": '"string"'}, does_not_raise()),
914985
({"type": "yaml", "default": "string"}, does_not_raise()),
915986
({"type": "yaml", "default": "1"}, does_not_raise()),

tests/test_prompt.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pexpect
1111
import pytest
1212
import yaml
13+
from coverage.tracer import CTracer
1314
from pexpect.popen_spawn import PopenSpawn
1415
from plumbum import local
1516

@@ -89,6 +90,23 @@
8990
"[[ _copier_conf.answers_file ]].tmpl": "[[_copier_answers|to_nice_yaml]]",
9091
}
9192

93+
PATH_TREE: Mapping[StrOrPath, str | bytes] = {
94+
"copier.yml": (
95+
f"""\
96+
_templates_suffix: {SUFFIX_TMPL}
97+
_envops: {BRACKET_ENVOPS_JSON}
98+
current_location:
99+
type: path
100+
default: /dev/warppipe0
101+
102+
star_location:
103+
type: path
104+
help: "Location of a bonus star"
105+
"""
106+
),
107+
"[[ _copier_conf.answers_file ]].tmpl": "[[_copier_answers|to_nice_yaml]]",
108+
}
109+
92110

93111
@pytest.mark.parametrize(
94112
"name, args",
@@ -156,6 +174,50 @@ def test_copy_default_advertised(
156174
assert load_answersfile_data(".").get("_commit") == "v2"
157175

158176

177+
@pytest.mark.skipif(
178+
condition=platform.system() == "Windows",
179+
reason="pexpect.spawn does not work on Windows",
180+
)
181+
def test_path_completion(tmp_path_factory: pytest.TempPathFactory) -> None:
182+
"""Test that file paths can handle tab completion."""
183+
from pexpect.pty_spawn import spawn as pexpect_spawn
184+
185+
src, dst, completedir = map(tmp_path_factory.mktemp, ("src", "dst", "my-directory"))
186+
with local.cwd(src):
187+
build_file_tree(PATH_TREE)
188+
git_save(tag="v1")
189+
git("commit", "--allow-empty", "-m", "v2")
190+
git("tag", "v2")
191+
with local.cwd(dst):
192+
# Disable subprocess timeout if debugging (except coverage)
193+
# See https://stackoverflow.com/a/67065084/1468388
194+
tracer = getattr(sys, "gettrace", lambda: None)()
195+
timeout = 10 if not isinstance(tracer, (CTracer, type(None))) else None
196+
197+
# Copy the v1 template
198+
cmd = COPIER_PATH + ("copy", str(src), ".", "--vcs-ref=v1")
199+
tui = pexpect_spawn(cmd[0], list(cmd[1:]), timeout=timeout)
200+
201+
# Check that default values are maintained
202+
expect_prompt(tui, "current_location", "path")
203+
tui.sendline()
204+
205+
# Check tab completion of a filesystem path (/path/to/my-direct<TAB> should
206+
# complete to /path/to/my-directory0)
207+
expect_prompt(tui, "star_location", "path", help="Location of a bonus star")
208+
tui.send(str(completedir)[:-4])
209+
tui.send(Keyboard.Tab)
210+
tui.sendline()
211+
tui.sendline()
212+
213+
tui.expect_exact(pexpect.EOF)
214+
215+
answers = load_answersfile_data(".")
216+
assert answers.get("_commit") == "v1"
217+
assert answers.get("current_location") == "/dev/warppipe0"
218+
assert answers.get("star_location") == str(completedir)
219+
220+
159221
@pytest.mark.parametrize(
160222
"name, args",
161223
[

0 commit comments

Comments
 (0)