Skip to content

Allow for .copier-answers.yaml to be default in backwards-compatible way#2196

Open
timkpaine wants to merge 4 commits intocopier-org:masterfrom
timkpaine:tkp/yaml
Open

Allow for .copier-answers.yaml to be default in backwards-compatible way#2196
timkpaine wants to merge 4 commits intocopier-org:masterfrom
timkpaine:tkp/yaml

Conversation

@timkpaine
Copy link

This is a very small backwards-compatible change.

Copier uses .copier-answers.yml as the default, and supports overriding. As discussed in #1077 .yml is the default suffix, not .yaml. It makes sense to not change the default, but perhaps it would be ok to start to check .copier-answers.yaml? This PR does that very small change, if the user has decided to use the default answers file but with .yaml suffix instead of .yml suffix, copier update will work without -a.

Copy link
Member

@sisp sisp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to supporting the .yaml suffix in a backwards-compatible way, but I'm afraid the solution is a little more involved.

  1. See my inline comment about your suggested change.
  2. The default answers file name/path is hardcoded in a few places: https://github.com/search?q=repo%3Acopier-org%2Fcopier+%22.copier-answers.yml%22+path%3A%2F%5Ecopier%5C%2F%2F&type=code This should be refactored anyway, but with a primary and secondary file name/path, we can't simply use a default value, as there isn't a single one anymore but rather some logic that first tries reading the primary file name/path and falls back to the secondary when the primary doesn't exist. Perhaps the simplest solution is to encode the default answers file name/path as None and only resolve None as .copier-answers.yml or .copier-answers.yaml in copier/_user_data.py::load_answersfile_data.

Whatever the solution looks like in the end, I have three general requests:

  1. I think the .yml suffix should take precedence over the .yaml suffix for maximum backwards compatibility.
  2. I think we should check for the existence of both files (.yml suffix and .yaml suffix) and raise a warning if both exist, just in case, e.g., a project was migrated from .yaml to .yml but forgot to remove the .yaml file.
  3. Please add tests.

WDYT?

copier/_main.py Outdated
@cached_property
def subproject(self) -> Subproject:
"""Get related subproject."""
default_answers_file = Path(".copier-answers.yaml") if Path(".copier-answers.yaml").exists() else Path(".copier-answers.yml")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't work because the Path object passed to the answers_relpath argument of Subproject(local_abspath=..., answers_relpath=...) is the path of the answers file relative to local_abspath. Path(".copier-answers.yaml").exists() checks whether .copier-answers.yaml exists in the current working directory, which is not guaranteed to be local_abspath.

@timkpaine
Copy link
Author

  1. I think the .yml suffix should take precedence over the .yaml suffix for maximum backwards compatibility.

Sounds good, unfortunately it will be slightly annoying as we'll have to make sure to look in the "right direction", e.g. in some places you want to check .yml first then default to .yaml, like when reading, but in others you want to check .yaml first then default to .yml, like when writing.

  1. I think we should check for the existence of both files (.yml suffix and .yaml suffix) and raise a warning if both exist, just in case, e.g., a project was migrated from .yaml to .yml but forgot to remove the .yaml file.

Makes sense to me.

  1. Please add tests.

Will do

@timkpaine
Copy link
Author

timkpaine commented Aug 14, 2025

coming back to this soon, apologies I got distracted with some other copier stuff.

@pawamoy
Copy link
Contributor

pawamoy commented Aug 14, 2025

Awesome presentation @timkpaine! Always nice to see our work presented in conferences 😍

Copy link
Member

@sisp sisp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reminding me of this PR, @timkpaine! 👍 And sorry for the delay ... 🫣

I've left a couple of inline comments.

Comment on lines +23 to +42
@pytest.fixture
def simple_template(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)
return root
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about moving this fixture to the TestCopyWithYamlAnswersFile class below? This is the only place where it is used.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about moving the contents of this file to the existing tests/test_answersfile.py file to avoid scattering somewhat related tests across multiple files?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I acknowledge your efforts to test the answers file resolution thoroughly. But I notice quite a few tests involving rather internal classes and functions which are likely subject to change because of internal refactoring once the public API has been cleaned up. What really matters is the intended answers file resolution behavior for a few user-level scenarios which can be tested with run_{recopy,update} calls. How about limiting tests to those scenarios and removing the tests involving non-public API?

Comment on lines +611 to +634
if answers_file is None:
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, fall back to .yaml
if yml_exists:
answers_file = DEFAULT_ANSWERS_FILE_YML
elif yaml_exists:
answers_file = DEFAULT_ANSWERS_FILE_YAML
else:
# Default to .yml when neither exists
answers_file = DEFAULT_ANSWERS_FILE_YML
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can call resolve_answersfile_path here to deduplicate this code block.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants