Skip to content

Commit 454ec42

Browse files
authored
feat: add --ask flag to force prompting of selected questions (copier-org#2705)
1 parent 9bf4f52 commit 454ec42

5 files changed

Lines changed: 450 additions & 26 deletions

File tree

copier/_cli.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ class CopierCopySubApp(_Subcommand):
239239
["-w", "--overwrite"],
240240
help="Overwrite files that already exist, without asking.",
241241
)
242+
ask = cli.SwitchAttr(
243+
["--ask"],
244+
str,
245+
list=True,
246+
help=(
247+
"Ask the questions matching the given glob-pattern, even if they would be "
248+
"skipped by other options"
249+
),
250+
)
242251

243252
def main(self, template_src: str, destination_path: str) -> int:
244253
"""Call [run_copy][copier.run_copy].
@@ -270,6 +279,7 @@ def inner() -> None:
270279
quiet=self.quiet,
271280
unsafe=self.unsafe,
272281
skip_tasks=self.skip_tasks,
282+
ask=self.ask,
273283
)
274284

275285
return _handle_exceptions(inner)
@@ -318,6 +328,15 @@ class CopierRecopySubApp(_Subcommand):
318328
default=False,
319329
help="Skip questions that have already been answered",
320330
)
331+
ask = cli.SwitchAttr(
332+
["--ask"],
333+
str,
334+
list=True,
335+
help=(
336+
"Ask the questions matching the given fnamtch-pattern, even if they would be "
337+
"skipped by other options"
338+
),
339+
)
321340

322341
def main(self, destination_path: str = ".") -> int:
323342
"""Call [run_recopy][copier.run_recopy].
@@ -347,6 +366,7 @@ def inner() -> None:
347366
unsafe=self.unsafe,
348367
skip_answered=self.skip_answered,
349368
skip_tasks=self.skip_tasks,
369+
ask=self.ask,
350370
)
351371

352372
return _handle_exceptions(inner)
@@ -401,6 +421,15 @@ class CopierUpdateSubApp(_Subcommand):
401421
default=False,
402422
help="Skip questions that have already been answered",
403423
)
424+
ask = cli.SwitchAttr(
425+
["--ask"],
426+
str,
427+
list=True,
428+
help=(
429+
"Ask the questions matching the given glob-pattern, even if they would be "
430+
"skipped by other options"
431+
),
432+
)
404433

405434
def main(self, destination_path: str = ".") -> int:
406435
"""Call [run_update][copier.run_update].
@@ -432,6 +461,7 @@ def inner() -> None:
432461
unsafe=self.unsafe,
433462
skip_answered=self.skip_answered,
434463
skip_tasks=self.skip_tasks,
464+
ask=self.ask,
435465
)
436466

437467
return _handle_exceptions(inner)

copier/_main.py

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from contextvars import ContextVar
1414
from dataclasses import field, replace
1515
from filecmp import dircmp
16+
from fnmatch import fnmatchcase
1617
from functools import cached_property, partial, wraps
1718
from itertools import chain
1819
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
@@ -228,6 +229,10 @@ class Worker:
228229
229230
skip_tasks:
230231
When `True`, skip template tasks execution.
232+
233+
ask:
234+
List of question names to ask, even if they would be skipped by other
235+
options. Supports glob-style patterns.
231236
"""
232237

233238
# NOTE: attributes are fully documented in [creating.md](../docs/creating.md)
@@ -253,6 +258,7 @@ class Worker:
253258
unsafe: bool = False
254259
skip_answered: bool = False
255260
skip_tasks: bool = False
261+
ask: Sequence[str] = ()
256262

257263
answers: AnswersMap = field(default_factory=AnswersMap, init=False)
258264
_cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)
@@ -622,35 +628,36 @@ def _ask(self) -> None: # noqa: C901
622628
# value.
623629
if question.get_default() is MISSING:
624630
continue
625-
if var_name in self.answers.init:
626-
# Try to parse and validate (if the question has a validator)
627-
# the answer value.
628-
answer = question.parse_answer(self.answers.init[var_name])
629-
question.validate_answer(answer)
630-
# At this point, the answer value is valid. Do not ask the
631-
# question again, but set answer as the user's answer instead.
632-
self.answers.user[var_name] = answer
633-
continue
634-
# Skip a question when the user already answered it.
635-
if self.skip_answered and var_name in self.answers.last:
636-
continue
637631

638-
# Display TUI and ask user interactively only without --defaults
639-
try:
632+
if not any(fnmatchcase(var_name, ask_pattern) for ask_pattern in self.ask):
633+
# If the user didn't explicitly request the question be asked,
634+
# it may now be skipped by `--data`, `--skip-answered`, or `--defaults`.
635+
if var_name in self.answers.init:
636+
# Try to parse and validate (if the question has a validator)
637+
# the answer value.
638+
answer = question.parse_answer(self.answers.init[var_name])
639+
question.validate_answer(answer)
640+
self.answers.user[var_name] = answer
641+
continue
642+
if self.skip_answered and var_name in self.answers.last:
643+
continue
640644
if self.defaults:
641-
new_answer = question.get_default()
642-
if new_answer is MISSING:
645+
answer = question.get_default()
646+
if answer is MISSING:
643647
raise ValueError(f'Question "{var_name}" is required')
644-
else:
645-
try:
646-
new_answer = unsafe_prompt(
647-
[question.get_questionary_structure()],
648-
answers={question.var_name: question.get_default()},
649-
)[question.var_name]
650-
except EOFError as err:
651-
raise InteractiveSessionError(
652-
"Use `--defaults` and/or `--data`/`--data-file`"
653-
) from err
648+
self.answers.user[var_name] = answer
649+
continue
650+
651+
# Display TUI and ask user interactively only without --defaults
652+
try:
653+
new_answer = unsafe_prompt(
654+
[question.get_questionary_structure()],
655+
answers={question.var_name: question.get_default()},
656+
)[question.var_name]
657+
except EOFError as err:
658+
raise InteractiveSessionError(
659+
"Use `--defaults` and/or `--data`/`--data-file`"
660+
) from err
654661
except KeyboardInterrupt as err:
655662
raise CopierAnswersInterrupt(
656663
self.answers, question, self.template
@@ -1385,6 +1392,7 @@ def _apply_update(self) -> None: # noqa: C901
13851392
# won't be included in the diff as deleted paths to prevent deletion.
13861393
# https://github.com/orgs/copier-org/discussions/2345
13871394
exclude=[*self.template.exclude, *self.exclude],
1395+
ask=(),
13881396
) as old_worker:
13891397
old_worker.run_copy()
13901398
# Run pre-migration tasks
@@ -1457,6 +1465,7 @@ def _apply_update(self) -> None: # noqa: C901
14571465
src_path=self.subproject.template.url, # type: ignore[union-attr]
14581466
exclude=exclude_plus_removed,
14591467
vcs_ref=self.resolved_vcs_ref,
1468+
ask=(),
14601469
) as new_worker:
14611470
new_worker.run_copy()
14621471
with local.cwd(new_copy):
@@ -1708,6 +1717,7 @@ def run_copy(
17081717
quiet: bool = False,
17091718
unsafe: bool = False,
17101719
skip_tasks: bool = False,
1720+
ask: Sequence[str] = (),
17111721
) -> Worker:
17121722
"""Copy a template to a destination, from zero."""
17131723
with Worker(
@@ -1736,6 +1746,7 @@ def run_copy(
17361746
quiet=quiet,
17371747
unsafe=unsafe,
17381748
skip_tasks=skip_tasks,
1749+
ask=ask,
17391750
) as worker:
17401751
worker.run_copy()
17411752
return worker
@@ -1760,6 +1771,7 @@ def run_recopy(
17601771
unsafe: bool = False,
17611772
skip_answered: bool = False,
17621773
skip_tasks: bool = False,
1774+
ask: Sequence[str] = (),
17631775
) -> Worker:
17641776
"""Update a subproject from its template, discarding subproject evolution."""
17651777
with Worker(
@@ -1788,6 +1800,7 @@ def run_recopy(
17881800
unsafe=unsafe,
17891801
skip_answered=skip_answered,
17901802
skip_tasks=skip_tasks,
1803+
ask=ask,
17911804
) as worker:
17921805
worker.run_recopy()
17931806
return worker
@@ -1814,6 +1827,7 @@ def run_update(
18141827
unsafe: bool = False,
18151828
skip_answered: bool = False,
18161829
skip_tasks: bool = False,
1830+
ask: Sequence[str] = (),
18171831
) -> Worker:
18181832
"""Update a subproject, from its template."""
18191833
with Worker(
@@ -1844,6 +1858,7 @@ def run_update(
18441858
unsafe=unsafe,
18451859
skip_answered=skip_answered,
18461860
skip_tasks=skip_tasks,
1861+
ask=ask,
18471862
) as worker:
18481863
worker.run_update()
18491864
return worker

docs/configuring.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,19 @@ Don't forget to read [the docs about the answers file](#the-copier-answersyml-fi
808808
_answers_file: .my-custom-answers.yml
809809
```
810810

811+
### `ask`
812+
813+
- Format: `List[str]`
814+
- CLI flags: `--ask`
815+
816+
Ask the matched questions, even if they would be skipped by other options such as
817+
[defaults](#defaults), [skip-answered](#skip_answered), or [data](#data). Names may be
818+
glob-style patterns.
819+
820+
!!! info
821+
822+
Not supported in `copier.yml`.
823+
811824
### `cleanup_on_error`
812825

813826
- Format: `bool`

tests/test_cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,9 @@ def test_copy_help(capsys: pytest.CaptureFixture[str]) -> None:
408408
extensions, migrations, tasks)
409409
-a, --answers-file VALUE:str Update using this path (relative to
410410
`destination_path`) to find the answers file
411+
--ask VALUE:str Ask the questions matching the given glob-
412+
pattern, even if they would be skipped by
413+
other options; may be given multiple times
411414
-d, --data VARIABLE=VALUE:str Make VARIABLE available as VALUE when
412415
rendering the template; may be given
413416
multiple times
@@ -475,6 +478,9 @@ def test_update_help(capsys: pytest.CaptureFixture[str]) -> None:
475478
extensions, migrations, tasks)
476479
-a, --answers-file VALUE:str Update using this path (relative to
477480
`destination_path`) to find the answers file
481+
--ask VALUE:str Ask the questions matching the given glob-
482+
pattern, even if they would be skipped by
483+
other options; may be given multiple times
478484
-c, --context-lines VALUE:int Lines of context to use for detecting
479485
conflicts. Increase for accuracy, decrease
480486
for resilience.; the default is 3

0 commit comments

Comments
 (0)