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
4 changes: 3 additions & 1 deletion copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1033,9 +1033,11 @@ def resolved_vcs_ref(self) -> str | None:
@cached_property
def subproject(self) -> Subproject:
"""Get related subproject."""
# answers_file=None means auto-detect based on existing
# files via resolve_answersfile_path
result = Subproject(
local_abspath=self.dst_path.absolute(),
answers_relpath=self.answers_file or Path(".copier-answers.yml"),
answers_relpath=self.answers_file,
)
self._cleanup_hooks.append(result._cleanup)
return result
Expand Down
16 changes: 14 additions & 2 deletions copier/_subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from ._template import Template
from ._types import AbsolutePath, AnyByStrDict, VCSTypes
from ._user_data import load_answersfile_data
from ._user_data import load_answersfile_data, resolve_answersfile_path
from ._vcs import get_git, is_in_git_repo


Expand All @@ -29,10 +29,11 @@ class Subproject:

answers_relpath:
Relative path to [the answers file][the-copier-answersyml-file].
If None, auto-detects between .copier-answers.yml and .copier-answers.yaml.
"""

local_abspath: AbsolutePath
answers_relpath: Path = Path(".copier-answers.yml")
answers_relpath: Path | None = None

_cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)

Expand All @@ -53,6 +54,17 @@ def _cleanup(self) -> None:
for method in self._cleanup_hooks:
method()

@cached_property
def resolved_answers_relpath(self) -> Path:
"""Get the resolved answers file path.

If answers_relpath was explicitly set, return it.
Otherwise, auto-detect based on existing files (.yml takes precedence).
"""
if self.answers_relpath is not None:
return self.answers_relpath
return resolve_answersfile_path(self.local_abspath)

@property
def _raw_answers(self) -> AnyByStrDict:
"""Get last answers, loaded raw as yaml."""
Expand Down
5 changes: 4 additions & 1 deletion copier/_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from ._tools import copier_version, handle_remove_readonly
from ._types import AnyByStrDict, VCSTypes
from ._user_data import DEFAULT_ANSWERS_FILE_YML
from ._vcs import clone, get_git, get_latest_tag, get_repo
from .errors import (
InvalidConfigFileError,
Expand Down Expand Up @@ -272,10 +273,12 @@ def answers_relpath(self) -> Path:
"""Get the answers file relative path, as specified in the template.

If not specified, returns the default `.copier-answers.yml`.
Note: The actual file used may be `.copier-answers.yaml` if that exists
and `.copier-answers.yml` does not. This is handled by the subproject.

See [answers_file][].
"""
result = Path(self.config_data.get("answers_file", ".copier-answers.yml"))
result = Path(self.config_data.get("answers_file", DEFAULT_ANSWERS_FILE_YML))
assert not result.is_absolute()
return result

Expand Down
62 changes: 60 additions & 2 deletions copier/_user_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
)
from .errors import InvalidTypeError, MissingFileWarning, UserMessageError

# Default answers file names, in order of precedence
DEFAULT_ANSWERS_FILE_YML = ".copier-answers.yml"
DEFAULT_ANSWERS_FILE_YAML = ".copier-answers.yaml"


# TODO Remove these two functions as well as DEFAULT_DATA in a future release
def _now() -> datetime:
Expand Down Expand Up @@ -583,11 +587,25 @@ def parse_yaml_list(string: str) -> list[str]:

def load_answersfile_data(
dst_path: StrOrPath,
answers_file: StrOrPath = ".copier-answers.yml",
answers_file: StrOrPath | None = None,
*,
warn_on_missing: bool = False,
) -> AnyByStrDict:
"""Load answers data from a `$dst_path/$answers_file` file if it exists."""
"""Load answers data from a `$dst_path/$answers_file` file if it exists.

Args:
dst_path: Path to the destination directory.
answers_file: Path to the answers file relative to dst_path.
If None, auto-detects by checking for .copier-answers.yml first,
then .copier-answers.yaml. The .yml extension takes precedence.
warn_on_missing: If True, warn when the answers file is not found.

Returns:
The loaded answers data, or an empty dict if not found.
"""
if answers_file is None:
answers_file = resolve_answersfile_path(dst_path)

try:
with Path(dst_path, answers_file).open("rb") as fd:
return yaml.safe_load(fd)
Expand All @@ -600,6 +618,46 @@ def load_answersfile_data(
return {}


def resolve_answersfile_path(dst_path: StrOrPath) -> Path:
"""Resolve which answers file to use for a given destination path.

For reading: Returns the existing answers file (.yml takes precedence over .yaml).
For writing: Returns the existing file to update, or .yml as default for new files.

Warns if both .yml and .yaml files exist.

Args:
dst_path: Path to the destination directory.

Returns:
Relative path to the answers file to use.
"""
dst = Path(dst_path)
yml_path = dst / DEFAULT_ANSWERS_FILE_YML
yaml_path = dst / DEFAULT_ANSWERS_FILE_YAML

yml_exists = yml_path.is_file()
yaml_exists = yaml_path.is_file()

# Warn if both files exist
if yml_exists and yaml_exists:
warnings.warn(
f"Both {DEFAULT_ANSWERS_FILE_YML} and {DEFAULT_ANSWERS_FILE_YAML} "
f"exist in {dst_path}. Using {DEFAULT_ANSWERS_FILE_YML}. "
"Please remove the duplicate file.",
stacklevel=2,
)

# .yml takes precedence for reading/updating
if yml_exists:
return Path(DEFAULT_ANSWERS_FILE_YML)
# If only .yaml exists, use it (backwards compat)
elif yaml_exists:
return Path(DEFAULT_ANSWERS_FILE_YAML)
# Default to .yml for new files
return Path(DEFAULT_ANSWERS_FILE_YML)


CAST_STR_TO_NATIVE: Mapping[str, Callable[[str], Any]] = {
"bool": cast_to_bool,
"float": float,
Expand Down
100 changes: 99 additions & 1 deletion tests/test_answersfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import yaml

import copier
from copier._user_data import load_answersfile_data
from copier._user_data import (
DEFAULT_ANSWERS_FILE_YAML,
DEFAULT_ANSWERS_FILE_YML,
load_answersfile_data,
)

from .helpers import BRACKET_ENVOPS_JSON, SUFFIX_TMPL, build_file_tree, git_save

Expand Down Expand Up @@ -328,3 +332,97 @@ def test_undefined_phase_in_external_data(
copier.run_copy(str(src), dst, defaults=True, overwrite=True)
answers = load_answersfile_data(dst, ".copier-answers.yml")
assert answers["key"] == "value"


class TestYamlAnswersFileSuffix:
"""Tests for .yaml answers file suffix support alongside .yml."""

@pytest.fixture
def simple_template(self, tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create a simple template for testing."""
root = tmp_path_factory.mktemp("template")
build_file_tree(
{
(root / "copier.yml"): dedent(
"""\
name:
type: str
default: test
"""
),
(root / "{{ _copier_conf.answers_file }}.jinja"): (
"{{ _copier_answers|tojson }}"
),
}
)
git_save(root, tag="v1")
return root

def test_new_project_uses_yml_by_default(
self, simple_template: Path, tmp_path: Path
) -> None:
"""New projects create .copier-answers.yml by default."""
copier.run_copy(str(simple_template), tmp_path, defaults=True)
assert (tmp_path / DEFAULT_ANSWERS_FILE_YML).exists()
assert not (tmp_path / DEFAULT_ANSWERS_FILE_YAML).exists()

def test_recopy_reads_yaml_answers(
self, simple_template: Path, tmp_path: Path
) -> None:
"""run_recopy can read answers from an existing .yaml file."""
copier.run_copy(str(simple_template), tmp_path, defaults=True)

# Rename .yml to .yaml to simulate a project using .yaml
(tmp_path / DEFAULT_ANSWERS_FILE_YML).rename(
tmp_path / DEFAULT_ANSWERS_FILE_YAML
)
git_save(tmp_path)

# run_recopy should auto-detect and read from .yaml
copier.run_recopy(tmp_path, defaults=True, overwrite=True)

# The template writes to .copier-answers.yml (its configured default),
# so after recopy both may exist. Verify the recopy succeeded
# by checking the newly written answers file is valid.
answers = load_answersfile_data(tmp_path)
assert answers["name"] == "test"

def test_update_reads_yaml_answers(
self, simple_template: Path, tmp_path: Path
) -> None:
"""run_update can read answers from an existing .yaml file."""
copier.run_copy(str(simple_template), tmp_path, defaults=True)

# Rename .yml to .yaml to simulate a project using .yaml
(tmp_path / DEFAULT_ANSWERS_FILE_YML).rename(
tmp_path / DEFAULT_ANSWERS_FILE_YAML
)
git_save(tmp_path)

# run_update should auto-detect and read from .yaml
copier.run_update(tmp_path, defaults=True, overwrite=True)

answers = load_answersfile_data(tmp_path)
assert answers["name"] == "test"

def test_yml_takes_precedence_over_yaml(
self, simple_template: Path, tmp_path: Path
) -> None:
""".yml file takes precedence when both .yml and .yaml exist."""
copier.run_copy(
str(simple_template), tmp_path, data={"name": "from_yml"}, defaults=True
)

# Create a .yaml file with different content
yaml_file = tmp_path / DEFAULT_ANSWERS_FILE_YAML
yml_answers = yaml.safe_load(
(tmp_path / DEFAULT_ANSWERS_FILE_YML).read_text()
)
yaml_file.write_text(yaml.dump({**yml_answers, "name": "from_yaml"}))
git_save(tmp_path)

# recopy should use .yml (which has "from_yml"), not .yaml
copier.run_recopy(tmp_path, defaults=True, overwrite=True)

answers = load_answersfile_data(tmp_path)
assert answers["name"] == "from_yml"