Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 22 additions & 69 deletions copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from pydantic import ConfigDict, PositiveInt
from pydantic.dataclasses import dataclass
from pydantic_core import to_jsonable_python
from questionary import confirm, unsafe_prompt
from questionary import confirm

from ._jinja_ext import YieldEnvironment, YieldExtension
from ._settings import Settings, SettingsModel, is_trusted_repository
Expand All @@ -55,7 +55,6 @@
set_git_alternates,
)
from ._types import (
MISSING,
AnyByStrDict,
AnyByStrMutableMapping,
JSONSerializable,
Expand All @@ -64,11 +63,11 @@
Phase,
RelativePath,
VcsRef,
unflatten,
)
from ._user_data import AnswersMap, Question, load_answersfile_data
from ._user_data import AnswersMap, GlobalState, QuestionNode, load_answersfile_data
from ._vcs import get_git
from .errors import (
CopierAnswersInterrupt,
ExtensionNotFoundError,
ForbiddenPathError,
InteractiveSessionError,
Expand Down Expand Up @@ -351,8 +350,11 @@ def _answers_to_remember(self) -> Mapping[str, Any]:
for (k, v) in self.answers.combined.items()
if not k.startswith("_")
and k not in self.answers.hidden
and k not in self.template.secret_questions
and k in self.template.questions_data
# and k not in self.template.secret_questions
and self.template.question_by_answer_key(
k, lambda q: not q.get("secret", False)
)
is not None
and isinstance(k, JSONSerializable)
and isinstance(v, JSONSerializable)
)
Expand Down Expand Up @@ -433,8 +435,9 @@ def _render_context(self) -> AnyByStrMutableMapping:
"os": lambda: OS,
}
)

return dict(
**self.answers.combined,
**unflatten(self.answers.combined),
_copier_answers=self._answers_to_remember(),
_copier_conf=conf,
_folder_name=self.subproject.local_abspath.name,
Expand Down Expand Up @@ -554,69 +557,19 @@ def _ask(self) -> None: # noqa: C901
external=self._external_data(),
)

for var_name, details in self.template.questions_data.items():
question = Question(
answers=self.answers,
context=self._render_context(),
jinja_env=self.jinja_env,
settings=self.settings,
var_name=var_name,
**details,
)
# Delete last answer if it cannot be parsed or validated, so a new
# valid answer can be provided.
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]
# Skip a question when the skip condition is met.
if not question.get_when():
# Omit its answer from the answers file.
self.answers.hide(var_name)
# Delete last answers to re-compute the answer from the default
# value (if it exists).
if var_name in self.answers.last:
del self.answers.last[var_name]
# Skip immediately to the next question when it has no default
# value.
if question.get_default() is MISSING:
continue
if var_name in self.answers.init:
# Try to parse and validate (if the question has a validator)
# the answer value.
answer = question.parse_answer(self.answers.init[var_name])
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
continue
# Skip a question when the user already answered it.
if self.skip_answered and var_name in self.answers.last:
continue
state = GlobalState(
_context_renderer=self._render_context,
template=self.template,
answers=self.answers,
jinja_env=self.jinja_env,
settings=self.settings,
defaults=self.defaults,
skip_answered=self.skip_answered,
)

# Display TUI and ask user interactively only without --defaults
try:
if self.defaults:
new_answer = question.get_default()
if new_answer is MISSING:
raise ValueError(f'Question "{var_name}" is required')
else:
try:
new_answer = unsafe_prompt(
[question.get_questionary_structure()],
answers={question.var_name: question.get_default()},
)[question.var_name]
except EOFError as err:
raise InteractiveSessionError(
"Use `--defaults` and/or `--data`/`--data-file`"
) from err
except KeyboardInterrupt as err:
raise CopierAnswersInterrupt(
self.answers, question, self.template
) from err
self.answers.user[var_name] = new_answer
for question_name, details in self.template.questions_data.items():
node = QuestionNode(question_name, details, state)
node.process()

# Reload external data, which may depend on answers
self.answers.external = self._external_data()
Expand Down
62 changes: 48 additions & 14 deletions copier/_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import re
import sys
from collections import ChainMap, defaultdict
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from contextlib import suppress
from dataclasses import field
from functools import cached_property
Expand All @@ -22,7 +22,7 @@
from plumbum.machines import local
from pydantic.dataclasses import dataclass

from ._tools import copier_version, handle_remove_readonly
from ._tools import copier_version, handle_remove_readonly, parse_dpath_path
from ._types import AnyByStrDict, VCSTypes
from ._vcs import checkout_latest_tag, clone, get_git, get_repo
from .errors import (
Expand Down Expand Up @@ -57,12 +57,21 @@ def filter_config(data: AnyByStrDict) -> tuple[AnyByStrDict, AnyByStrDict]:
config_data[k[1:]] = v
else:
# Transform simplified questions format into complex
if not isinstance(v, dict):
v = {"default": v}
questions_data[k] = v
questions_data[k] = _normalize_question_data(v)
return config_data, questions_data


def _normalize_question_data(q: Any) -> dict[str, Any]:
"""Normalize question data to ensure it has a dictionary structure."""
if not isinstance(q, dict):
return {"default": q}
if q.get("type", "yaml") == "dict":
subq = q.get("items", {})
for k, v in subq.items():
q["items"][k] = _normalize_question_data(v)
return q


def load_template_config(conf_path: Path, quiet: bool = False) -> AnyByStrDict:
"""Load the `copier.yml` file.

Expand Down Expand Up @@ -478,17 +487,42 @@ def questions_data(self) -> AnyByStrDict:
result[key]["secret"] = True
return result

@cached_property
def secret_questions(self) -> set[str]:
"""Get names of secret questions from the template.
def question_by_answer_key(
self, answer_key: str, *filters: Callable[[AnyByStrDict], bool]
) -> AnyByStrDict | None:
"""Get a question by its answer key.

Args:
answer_key: The key of the answer to find the question for.
filters: Optional filters to apply to the question data.

These questions shouldn't be saved into the answers file.
Returns:
The question data if found, or `None` if not found.
"""
result = set(self.config_data.get("secret_questions", []))
for key, value in self.questions_data.items():
if value.get("secret"):
result.add(key)
return result
answer_paths = parse_dpath_path(answer_key)
questions = self.questions_data
q: AnyByStrDict | None = None
for i, p in enumerate(answer_paths):
if i == 0:
if p not in questions:
return None
q = questions[p]
continue
if p.isdigit() and q is not None and q.get("size", None) is not None:
continue
questions = (
q["items"] if q is not None and q.get("type", "yaml") == "dict" else {}
)
if p not in questions:
return None
q = questions[p]

if q is not None:
for filter_func in filters:
if not filter_func(q):
return None

return q

@cached_property
def skip_if_exists(self) -> Sequence[str]:
Expand Down
9 changes: 9 additions & 0 deletions copier/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,12 @@ def try_enum(enum_type: type[_E], value: _T) -> _E | _T:
return enum_type(value)
except ValueError:
return value


def parse_dpath_path(text: str) -> list[str]:
"""Convert a text to a dpath path.

This is used to convert text input into a valid dpath path.
"""
key_parts = re.split(r"(?<!\\)\.", text)
return [part.replace(r"\.", ".") for part in key_parts]
12 changes: 12 additions & 0 deletions copier/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
TypeVar,
)

import dpath
from pydantic import AfterValidator

from ._tools import parse_dpath_path

# simple types
StrOrPath = str | Path
AnyByStrDict = dict[str, Any]
Expand Down Expand Up @@ -131,3 +134,12 @@ class VcsRef(Enum):
"""A special value to indicate that the current ref of the existing
template should be used.
"""


def unflatten(answers: Mapping[str, Any]) -> AnyByStrDict:
"""Unflatten a dictionary with dot-separated keys."""
result: AnyByStrDict = {}
for key, value in answers.items():
path = parse_dpath_path(key)
dpath.new(result, path, value)
return result
Loading