Skip to content

Commit 85e9735

Browse files
committed
Handle yaml vs yml ordering
1 parent 370337f commit 85e9735

5 files changed

Lines changed: 358 additions & 8 deletions

File tree

copier/_main.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
StrOrPath,
6666
VcsRef,
6767
)
68-
from ._user_data import AnswersMap, Question, load_answersfile_data
68+
from ._user_data import AnswersMap, Question, load_answersfile_data, resolve_answersfile_path
6969
from ._vcs import get_git
7070
from .errors import (
7171
CopierAnswersInterrupt,
@@ -1027,10 +1027,11 @@ def resolved_vcs_ref(self) -> str | None:
10271027
@cached_property
10281028
def subproject(self) -> Subproject:
10291029
"""Get related subproject."""
1030-
default_answers_file = Path(".copier-answers.yaml") if Path(".copier-answers.yaml").exists() else Path(".copier-answers.yml")
1030+
# answers_file=None means auto-detect based on existing files
1031+
# The Subproject will use resolve_answersfile_path to determine which file to use
10311032
result = Subproject(
10321033
local_abspath=self.dst_path.absolute(),
1033-
answers_relpath=self.answers_file or default_answers_file,
1034+
answers_relpath=self.answers_file,
10341035
)
10351036
self._cleanup_hooks.append(result._cleanup)
10361037
return result

copier/_subproject.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from ._template import Template
1717
from ._types import AbsolutePath, AnyByStrDict, VCSTypes
18-
from ._user_data import load_answersfile_data
18+
from ._user_data import load_answersfile_data, resolve_answersfile_path
1919
from ._vcs import get_git, is_in_git_repo
2020

2121

@@ -29,10 +29,11 @@ class Subproject:
2929
3030
answers_relpath:
3131
Relative path to [the answers file][the-copier-answersyml-file].
32+
If None, auto-detects between .copier-answers.yml and .copier-answers.yaml.
3233
"""
3334

3435
local_abspath: AbsolutePath
35-
answers_relpath: Path = Path(".copier-answers.yml")
36+
answers_relpath: Path | None = None
3637

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

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

57+
@cached_property
58+
def resolved_answers_relpath(self) -> Path:
59+
"""Get the resolved answers file path.
60+
61+
If answers_relpath was explicitly set, return it.
62+
Otherwise, auto-detect based on existing files (.yml takes precedence).
63+
"""
64+
if self.answers_relpath is not None:
65+
return self.answers_relpath
66+
return resolve_answersfile_path(self.local_abspath)
67+
5668
@property
5769
def _raw_answers(self) -> AnyByStrDict:
5870
"""Get last answers, loaded raw as yaml."""

copier/_template.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from ._tools import copier_version, handle_remove_readonly
2626
from ._types import AnyByStrDict, VCSTypes
27+
from ._user_data import DEFAULT_ANSWERS_FILE_YML
2728
from ._vcs import checkout_latest_tag, clone, get_git, get_repo
2829
from .errors import (
2930
InvalidConfigFileError,
@@ -272,10 +273,12 @@ def answers_relpath(self) -> Path:
272273
"""Get the answers file relative path, as specified in the template.
273274
274275
If not specified, returns the default `.copier-answers.yml`.
276+
Note: The actual file used may be `.copier-answers.yaml` if that exists
277+
and `.copier-answers.yml` does not. This is handled by the subproject.
275278
276279
See [answers_file][].
277280
"""
278-
result = Path(self.config_data.get("answers_file", ".copier-answers.yml"))
281+
result = Path(self.config_data.get("answers_file", DEFAULT_ANSWERS_FILE_YML))
279282
assert not result.is_absolute()
280283
return result
281284

copier/_user_data.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
)
4040
from .errors import InvalidTypeError, MissingFileWarning, UserMessageError
4141

42+
# Default answers file names, in order of precedence
43+
DEFAULT_ANSWERS_FILE_YML = ".copier-answers.yml"
44+
DEFAULT_ANSWERS_FILE_YAML = ".copier-answers.yaml"
45+
4246

4347
# TODO Remove these two functions as well as DEFAULT_DATA in a future release
4448
def _now() -> datetime:
@@ -585,11 +589,50 @@ def parse_yaml_list(string: str) -> list[str]:
585589

586590
def load_answersfile_data(
587591
dst_path: StrOrPath,
588-
answers_file: StrOrPath = ".copier-answers.yml",
592+
answers_file: StrOrPath | None = None,
589593
*,
590594
warn_on_missing: bool = False,
591595
) -> AnyByStrDict:
592-
"""Load answers data from a `$dst_path/$answers_file` file if it exists."""
596+
"""Load answers data from a `$dst_path/$answers_file` file if it exists.
597+
598+
Args:
599+
dst_path: Path to the destination directory.
600+
answers_file: Path to the answers file relative to dst_path.
601+
If None, auto-detects by checking for .copier-answers.yml first,
602+
then .copier-answers.yaml. The .yml extension takes precedence.
603+
warn_on_missing: If True, warn when the answers file is not found.
604+
605+
Returns:
606+
The loaded answers data, or an empty dict if not found.
607+
"""
608+
dst = Path(dst_path)
609+
610+
# If answers_file is None, auto-detect
611+
if answers_file is None:
612+
yml_path = dst / DEFAULT_ANSWERS_FILE_YML
613+
yaml_path = dst / DEFAULT_ANSWERS_FILE_YAML
614+
615+
yml_exists = yml_path.is_file()
616+
yaml_exists = yaml_path.is_file()
617+
618+
# Warn if both files exist
619+
if yml_exists and yaml_exists:
620+
warnings.warn(
621+
f"Both {DEFAULT_ANSWERS_FILE_YML} and {DEFAULT_ANSWERS_FILE_YAML} "
622+
f"exist in {dst_path}. Using {DEFAULT_ANSWERS_FILE_YML}. "
623+
"Please remove the duplicate file.",
624+
stacklevel=2,
625+
)
626+
627+
# .yml takes precedence, fall back to .yaml
628+
if yml_exists:
629+
answers_file = DEFAULT_ANSWERS_FILE_YML
630+
elif yaml_exists:
631+
answers_file = DEFAULT_ANSWERS_FILE_YAML
632+
else:
633+
# Default to .yml when neither exists
634+
answers_file = DEFAULT_ANSWERS_FILE_YML
635+
593636
try:
594637
with Path(dst_path, answers_file).open("rb") as fd:
595638
return yaml.safe_load(fd)
@@ -602,6 +645,46 @@ def load_answersfile_data(
602645
return {}
603646

604647

648+
def resolve_answersfile_path(dst_path: StrOrPath) -> Path:
649+
"""Resolve which answers file to use for a given destination path.
650+
651+
For reading: Returns the existing answers file (.yml takes precedence over .yaml).
652+
For writing: Returns the existing file to update, or .yml as default for new files.
653+
654+
Warns if both .yml and .yaml files exist.
655+
656+
Args:
657+
dst_path: Path to the destination directory.
658+
659+
Returns:
660+
Relative path to the answers file to use.
661+
"""
662+
dst = Path(dst_path)
663+
yml_path = dst / DEFAULT_ANSWERS_FILE_YML
664+
yaml_path = dst / DEFAULT_ANSWERS_FILE_YAML
665+
666+
yml_exists = yml_path.is_file()
667+
yaml_exists = yaml_path.is_file()
668+
669+
# Warn if both files exist
670+
if yml_exists and yaml_exists:
671+
warnings.warn(
672+
f"Both {DEFAULT_ANSWERS_FILE_YML} and {DEFAULT_ANSWERS_FILE_YAML} "
673+
f"exist in {dst_path}. Using {DEFAULT_ANSWERS_FILE_YML}. "
674+
"Please remove the duplicate file.",
675+
stacklevel=2,
676+
)
677+
678+
# .yml takes precedence for reading/updating
679+
if yml_exists:
680+
return Path(DEFAULT_ANSWERS_FILE_YML)
681+
# If only .yaml exists, use it (maintains backwards compatibility for .yaml projects)
682+
elif yaml_exists:
683+
return Path(DEFAULT_ANSWERS_FILE_YAML)
684+
# Default to .yml for new files
685+
return Path(DEFAULT_ANSWERS_FILE_YML)
686+
687+
605688
CAST_STR_TO_NATIVE: Mapping[str, Callable[[str], Any]] = {
606689
"bool": cast_to_bool,
607690
"float": float,

0 commit comments

Comments
 (0)