From d6f226f2b33a49db98ae63b48ba496b598b67637 Mon Sep 17 00:00:00 2001 From: Adrian Freund Date: Wed, 15 May 2024 11:19:11 +0200 Subject: [PATCH] feat: add simpler migrations configuration syntax (#1510) --- copier/main.py | 67 +- copier/template.py | 112 ++- docs/configuring.md | 110 ++- .../[[ _copier_conf.answers_file ]].tmpl | 0 .../copier.yaml | 0 .../delete-in-migration-v2.txt | 0 .../delete-in-tasks.txt | 0 .../migrations.py | 0 .../tasks.py | 0 tests/helpers.py | 15 +- tests/test_config.py | 8 +- tests/test_legacy_migration.py | 314 +++++++++ tests/test_migrations.py | 662 ++++++++++++------ tests/test_tasks.py | 23 +- 14 files changed, 1002 insertions(+), 309 deletions(-) rename tests/{demo_migrations => demo_legacy_migrations}/[[ _copier_conf.answers_file ]].tmpl (100%) rename tests/{demo_migrations => demo_legacy_migrations}/copier.yaml (100%) rename tests/{demo_migrations => demo_legacy_migrations}/delete-in-migration-v2.txt (100%) rename tests/{demo_migrations => demo_legacy_migrations}/delete-in-tasks.txt (100%) rename tests/{demo_migrations => demo_legacy_migrations}/migrations.py (100%) rename tests/{demo_migrations => demo_legacy_migrations}/tasks.py (100%) create mode 100644 tests/test_legacy_migration.py diff --git a/copier/main.py b/copier/main.py index bb8fcfdd3..c862f34ec 100644 --- a/copier/main.py +++ b/copier/main.py @@ -21,6 +21,7 @@ Literal, Mapping, Sequence, + TypeVar, get_args, overload, ) @@ -45,11 +46,19 @@ ) from .subproject import Subproject from .template import Task, Template -from .tools import OS, Style, normalize_git_path, printf, readlink -from .types import MISSING, AnyByStrDict, JSONSerializable, RelativePath, StrOrPath +from .tools import OS, Style, cast_to_bool, normalize_git_path, printf, readlink +from .types import ( + MISSING, + AnyByStrDict, + JSONSerializable, + RelativePath, + StrOrPath, +) from .user_data import DEFAULT_DATA, AnswersMap, Question from .vcs import get_git +_T = TypeVar("_T") + @dataclass(config=ConfigDict(extra="forbid")) class Worker: @@ -195,12 +204,14 @@ def __enter__(self) -> Worker: return self @overload - def __exit__(self, type: None, value: None, traceback: None) -> None: ... + def __exit__(self, type: None, value: None, traceback: None) -> None: + ... @overload def __exit__( self, type: type[BaseException], value: BaseException, traceback: TracebackType - ) -> None: ... + ) -> None: + ... def __exit__( self, @@ -277,13 +288,21 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None: tasks: The list of tasks to run. """ for i, task in enumerate(tasks): + extra_context = {f"_{k}": v for k, v in task.extra_vars.items()} + + if not cast_to_bool(self._render_value(task.condition, extra_context)): + continue + task_cmd = task.cmd if isinstance(task_cmd, str): - task_cmd = self._render_string(task_cmd) + task_cmd = self._render_string(task_cmd, extra_context) use_shell = True else: - task_cmd = [self._render_string(str(part)) for part in task_cmd] + task_cmd = [ + self._render_string(str(part), extra_context) for part in task_cmd + ] use_shell = False + if not self.quiet: print( colors.info @@ -292,7 +311,15 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None: ) if self.pretend: continue - with local.cwd(self.subproject.local_abspath), local.env(**task.extra_env): + + working_directory = ( + # We can't use _render_path here, as that function has special handling for files in the template + self.subproject.local_abspath + / Path(self._render_string(str(task.working_directory), extra_context)) + ).absolute() + + extra_env = {k.upper(): str(v) for k, v in task.extra_vars.items()} + with local.cwd(working_directory), local.env(**extra_env): subprocess.run(task_cmd, shell=use_shell, check=True, env=local.env) def _render_context(self) -> Mapping[str, Any]: @@ -709,15 +736,37 @@ def _render_path(self, relpath: Path) -> Path | None: return None return result - def _render_string(self, string: str) -> str: + def _render_string( + self, string: str, extra_context: AnyByStrDict | None = None + ) -> str: """Render one templated string. Args: string: The template source string. + + extra_context: + Additional variables to use for rendering the template. """ tpl = self.jinja_env.from_string(string) - return tpl.render(**self._render_context()) + return tpl.render(**self._render_context(), **(extra_context or {})) + + def _render_value( + self, value: _T, extra_context: AnyByStrDict | None = None + ) -> str | _T: + """Render a value, which may or may not be a templated string. + + Args: + value: + The value to render. + + extra_context: + Additional variables to use for rendering the template. + """ + try: + return self._render_string(value, extra_context=extra_context) # type: ignore[arg-type] + except TypeError: + return value @cached_property def subproject(self) -> Subproject: diff --git a/copier/template.py b/copier/template.py index 0a5d98330..feb71db54 100644 --- a/copier/template.py +++ b/copier/template.py @@ -28,7 +28,7 @@ UnsupportedVersionError, ) from .tools import copier_version, handle_remove_readonly -from .types import AnyByStrDict, Env, VCSTypes +from .types import AnyByStrDict, VCSTypes from .vcs import checkout_latest_tag, clone, get_git, get_repo # Default list of files in the template to exclude from the rendered project @@ -153,12 +153,26 @@ class Task: cmd: Command to execute. - extra_env: - Additional environment variables to set while executing the command. + extra_vars: + Additional variables for the task. + Will be available as Jinja variables for rendering of `cmd`, `condition` + and `working_directory` and as environment variables while the task is + running. + As Jinja variables they will be prefixed by an underscore, while as + environment variables they will be upper cased. + + condition: + The condition when a conditional task runs. + + working_directory: + The directory from inside where to execute the task. + If `None`, the project directory will be used. """ cmd: str | Sequence[str] - extra_env: Env = field(default_factory=dict) + extra_vars: dict[str, Any] = field(default_factory=dict) + condition: str | bool = True + working_directory: Path = Path(".") @dataclass @@ -370,27 +384,64 @@ def migration_tasks( """ result: list[Task] = [] if not (self.version and from_template.version): - return result - extra_env: Env = { - "STAGE": stage, - "VERSION_FROM": str(from_template.commit), - "VERSION_TO": str(self.commit), - "VERSION_PEP440_FROM": str(from_template.version), - "VERSION_PEP440_TO": str(self.version), + return [] + extra_vars: dict[str, Any] = { + "stage": stage, + "version_from": from_template.commit, + "version_to": self.commit, + "version_pep440_from": from_template.version, + "version_pep440_to": self.version, } migration: dict[str, Any] for migration in self._raw_config.get("_migrations", []): - current = parse(migration["version"]) - if self.version >= current > from_template.version: - extra_env = { - **extra_env, - "VERSION_CURRENT": migration["version"], - "VERSION_PEP440_CURRENT": str(current), - } - result.extend( - Task(cmd=cmd, extra_env=extra_env) - for cmd in migration.get(stage, []) + if any(key in migration for key in ("before", "after")): + # Legacy configuration format + warn( + "This migration configuration is deprecated. Please switch to the new format.", + category=DeprecationWarning, ) + current = parse(migration["version"]) + if self.version >= current > from_template.version: + extra_vars = { + **extra_vars, + "version_current": migration["version"], + "version_pep440_current": current, + } + result.extend( + Task(cmd=cmd, extra_vars=extra_vars) + for cmd in migration.get(stage, []) + ) + else: + # New configuration format + if isinstance(migration, (str, list)): + result.append( + Task( + cmd=migration, + extra_vars=extra_vars, + condition='{{ _stage == "after" }}', + ) + ) + else: + condition = migration.get("when", '{{ _stage == "after" }}') + working_directory = Path(migration.get("working_directory", ".")) + if "version" in migration: + current = parse(migration["version"]) + if not (self.version >= current > from_template.version): + continue + extra_vars = { + **extra_vars, + "version_current": migration["version"], + "version_pep440_current": current, + } + result.append( + Task( + cmd=migration["command"], + extra_vars=extra_vars, + condition=condition, + working_directory=working_directory, + ) + ) + return result @cached_property @@ -456,10 +507,21 @@ def tasks(self) -> Sequence[Task]: See [tasks][]. """ - return [ - Task(cmd=cmd, extra_env={"STAGE": "task"}) - for cmd in self.config_data.get("tasks", []) - ] + extra_vars = {"stage": "task"} + tasks = [] + for task in self.config_data.get("tasks", []): + if isinstance(task, dict): + tasks.append( + Task( + cmd=task["command"], + extra_vars=extra_vars, + condition=task.get("when", "true"), + working_directory=Path(task.get("working_directory", ".")), + ) + ) + else: + tasks.append(Task(cmd=task, extra_vars=extra_vars)) + return tasks @cached_property def templates_suffix(self) -> str: diff --git a/docs/configuring.md b/docs/configuring.md index 91bcbd7f6..95577bcbf 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -1146,43 +1146,55 @@ Like [`message_before_copy`][message_after_copy] but printed before ### `migrations` -- Format: `List[dict]` +- Format: `List[str|List[str]|dict]` - CLI flags: N/A - Default value: `[]` -Migrations are like [tasks][tasks], but each item in the list is a `dict` with these -keys: +Migrations are like [tasks][tasks], but each item can have additional keys: -- **version**: Indicates the version that the template update has to go through to - trigger this migration. It is evaluated using [PEP 440][]. -- **before** (optional): Commands to execute before performing the update. The answers - file is reloaded after running migrations in this stage, to let you migrate answer - values. -- **after** (optional): Commands to execute after performing the update. +- **command**: The migration command to run +- **version** (optional): Indicates the version that the template update has to go + through to trigger this migration. It is evaluated using [PEP 440][]. If no version + is specified the migration will run on every update. +- **when** (optional): Specifies a condition that needs to hold for the task to run. + By default, a migration will run in the after upgrade stage. +- **working_directory** (optional): Specifies the directory in which the command will + be run. Defaults to the destination directory. + +If a `str` or `List[str]` is given as a migrator it will be treated as `command` with +all other items not present. Migrations will run in the same order as declared here (so you could even run a migration for a higher version before running a migration for a lower version if the higher one is declared before and the update passes through both). -They will only run when _new version >= declared version > old version_. And only when -updating (not when copying for the 1st time). +When `version` is given they will only run when _new version >= declared version > old +version_. Your template will only be marked as [unsafe][unsafe] if this condition is +true. Migrations will also only run when updating (not when copying for the 1st time). If the migrations definition contains Jinja code, it will be rendered with the same context as the rest of the template. -Migration processes will receive these environment variables: - -- `$STAGE`: Either `before` or `after`. -- `$VERSION_FROM`: [Git commit description][git describe] of the template as it was - before updating. -- `$VERSION_TO`: [Git commit description][git describe] of the template as it will be - after updating. -- `$VERSION_CURRENT`: The `version` detector as you indicated it when describing - migration tasks. -- `$VERSION_PEP440_FROM`, `$VERSION_PEP440_TO`, `$VERSION_PEP440_CURRENT`: Same as the - above, but normalized into a standard [PEP 440][] version string indicator. If your - scripts use these environment variables to perform migrations, you probably will - prefer to use these variables. +There are a number of additional variables available for templating of migrations. Those +variables are also passed to the migration process as environment variables. Migration +processes will receive these variables: + +- `_stage`/`$STAGE`: Either `before` or `after`. +- `_version_from`/`$VERSION_FROM`: [Git commit description][git describe] of the + template as it was before updating. +- `_version_to`/`$VERSION_TO`: [Git commit description][git describe] of the template + as it will be after updating. +- `_version_current`/`$VERSION_CURRENT`: The `version` detector as you indicated it + when describing migration tasks (only when `version` is given). +- `_version_pep440_from`/`$VERSION_PEP440_FROM`, + `_version_pep440_to`/`$VERSION_PEP440_TO`, + `_version_pep440_current`/`$VERSION_PEP440_CURRENT`: Same as the above, but + normalized into a standard [PEP 440][] version. In Jinja templates these are + represented as + [packaging.version.Version](https://packaging.pypa.io/en/stable/version.html#packaging.version.Version) + objects and allow access to their attributes. As environment variables they are + represented as strings. If you use variables to perform migrations, you probably + will prefer to use these variables. [git describe]: https://git-scm.com/docs/git-describe [pep 440]: https://www.python.org/dev/peps/pep-0440/ @@ -1191,15 +1203,29 @@ Migration processes will receive these environment variables: ```yaml title="copier.yml" _migrations: - - version: v1.0.0 - before: - - rm ./old-folder - after: - # {{ _copier_conf.src_path }} points to the path where the template was - # cloned, so it can be helpful to run migration scripts stored there. - - invoke -r {{ _copier_conf.src_path }} -c migrations migrate $VERSION_CURRENT + # {{ _copier_conf.src_path }} points to the path where the template was + # cloned, so it can be helpful to run migration scripts stored there. + - invoke -r {{ _copier_conf.src_path }} -c migrations migrate $STAGE $VERSION_FROM $VERSION_TO + - version: v1.0.0 + command: rm ./old-folder + when: "{{ _stage == 'before' }}" ``` +In Copier versions before v9.3.0 a different configuration format had to be used. This +format is still available, but will raise a warning when used. + +Each item in the list is a `dict` with the following keys: + +- **version**: Indicates the version that the template update has to go through to + trigger this migration. It is evaluated using [PEP 440][]. +- **before** (optional): Commands to execute before performing the update. The answers + file is reloaded after running migrations in this stage, to let you migrate answer + values. +- **after** (optional): Commands to execute after performing the update. + +The migration variables mentioned above are available as environment variables, but +can't be used in jinja templates. + ### `min_copier_version` - Format: `str` @@ -1408,7 +1434,7 @@ This allows you to keep separate the template metadata and the template code. ### `tasks` -- Format: `List[str|List[str]]` +- Format: `List[str|List[str]|dict]` - CLI flags: N/A - Default value: `[]` @@ -1416,6 +1442,16 @@ Commands to execute after generating or updating a project from your template. They run ordered, and with the `$STAGE=task` variable in their environment. +If a `dict` is given it can contain the following items: + +- **command**: The task command to run. +- **when** (optional): Specifies a condition that needs to hold for the task to run. +- **working_directory** (optional): Specifies the directory in which the command will + be run. Defaults to the destination directory. + +If a `str` or `List[str]` is given as a task it will be treated as `command` with all +other items not present. + !!! example ```yaml title="copier.yml" @@ -1430,12 +1466,10 @@ They run ordered, and with the `$STAGE=task` variable in their environment. # Your script can be run by the same Python environment used to run Copier - ["{{ _copier_python }}", task.py] # OS-specific task (supported values are "linux", "macos", "windows" and `None`) - - >- - {% if _copier_conf.os in ['linux', 'macos'] %} - rm {{ name_of_the_project }}/README.md - {% elif _copier_conf.os == 'windows' %} - Remove-Item {{ name_of_the_project }}/README.md - {% endif %} + - command: rm {{ name_of_the_project }}/README.md + when: "{{ _copier_conf.os in ['linux', 'macos'] }}" + - command: Remove-Item {{ name_of_the_project }}\\README.md + when: "{{ _copier_conf.os == 'windows' }}" ``` Note: the example assumes you use [Invoke](https://www.pyinvoke.org/) as diff --git a/tests/demo_migrations/[[ _copier_conf.answers_file ]].tmpl b/tests/demo_legacy_migrations/[[ _copier_conf.answers_file ]].tmpl similarity index 100% rename from tests/demo_migrations/[[ _copier_conf.answers_file ]].tmpl rename to tests/demo_legacy_migrations/[[ _copier_conf.answers_file ]].tmpl diff --git a/tests/demo_migrations/copier.yaml b/tests/demo_legacy_migrations/copier.yaml similarity index 100% rename from tests/demo_migrations/copier.yaml rename to tests/demo_legacy_migrations/copier.yaml diff --git a/tests/demo_migrations/delete-in-migration-v2.txt b/tests/demo_legacy_migrations/delete-in-migration-v2.txt similarity index 100% rename from tests/demo_migrations/delete-in-migration-v2.txt rename to tests/demo_legacy_migrations/delete-in-migration-v2.txt diff --git a/tests/demo_migrations/delete-in-tasks.txt b/tests/demo_legacy_migrations/delete-in-tasks.txt similarity index 100% rename from tests/demo_migrations/delete-in-tasks.txt rename to tests/demo_legacy_migrations/delete-in-tasks.txt diff --git a/tests/demo_migrations/migrations.py b/tests/demo_legacy_migrations/migrations.py similarity index 100% rename from tests/demo_migrations/migrations.py rename to tests/demo_legacy_migrations/migrations.py diff --git a/tests/demo_migrations/tasks.py b/tests/demo_legacy_migrations/tasks.py similarity index 100% rename from tests/demo_migrations/tasks.py rename to tests/demo_legacy_migrations/tasks.py diff --git a/tests/helpers.py b/tests/helpers.py index aa7d743f3..9eb1840f4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -59,9 +59,14 @@ BRACKET_ENVOPS_JSON = json.dumps(BRACKET_ENVOPS) SUFFIX_TMPL = ".tmpl" +COPIER_ANSWERS_FILE: Mapping[StrOrPath, str | bytes | Path] = { + "{{ _copier_conf.answers_file }}.jinja": ("{{ _copier_answers|tojson }}") +} + class Spawn(Protocol): - def __call__(self, cmd: tuple[str, ...], *, timeout: int | None) -> PopenSpawn: ... + def __call__(self, cmd: tuple[str, ...], *, timeout: int | None) -> PopenSpawn: + ... class Keyboard(str, Enum): @@ -135,7 +140,10 @@ def expect_prompt( def git_save( - dst: StrOrPath = ".", message: str = "Test commit", tag: str | None = None + dst: StrOrPath = ".", + message: str = "Test commit", + tag: str | None = None, + allow_empty: bool = False, ) -> None: """Save the current repo state in git. @@ -143,11 +151,12 @@ def git_save( dst: Path to the repo to save. message: Commit message. tag: Tag to create, optionally. + allow_empty: Allow creating a commit with no changes """ with local.cwd(dst): git("init") git("add", ".") - git("commit", "-m", message) + git("commit", "-m", message, *(["--allow-empty"] if allow_empty else [])) if tag: git("tag", tag) diff --git a/tests/test_config.py b/tests/test_config.py index 7889f033b..1c13dd146 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -33,8 +33,8 @@ def test_config_data_is_loaded_from_file() -> None: assert tpl.exclude == ("exclude1", "exclude2") assert tpl.skip_if_exists == ["skip_if_exists1", "skip_if_exists2"] assert tpl.tasks == [ - Task(cmd="touch 1", extra_env={"STAGE": "task"}), - Task(cmd="touch 2", extra_env={"STAGE": "task"}), + Task(cmd="touch 1", extra_vars={"stage": "task"}), + Task(cmd="touch 2", extra_vars={"stage": "task"}), ] @@ -296,8 +296,8 @@ def test_worker_good_data(tmp_path: Path) -> None: assert conf.all_exclusions == ("exclude1", "exclude2") assert conf.template.skip_if_exists == ["skip_if_exists1", "skip_if_exists2"] assert conf.template.tasks == [ - Task(cmd="touch 1", extra_env={"STAGE": "task"}), - Task(cmd="touch 2", extra_env={"STAGE": "task"}), + Task(cmd="touch 1", extra_vars={"stage": "task"}), + Task(cmd="touch 2", extra_vars={"stage": "task"}), ] diff --git a/tests/test_legacy_migration.py b/tests/test_legacy_migration.py new file mode 100644 index 000000000..c560127e0 --- /dev/null +++ b/tests/test_legacy_migration.py @@ -0,0 +1,314 @@ +import json +import platform +from pathlib import Path +from shutil import copytree + +import pytest +import yaml +from plumbum import local +from plumbum.cmd import git + +from copier import run_copy, run_update +from copier.errors import UserMessageError + +from .helpers import BRACKET_ENVOPS_JSON, PROJECT_TEMPLATE, build_file_tree + +SRC = Path(f"{PROJECT_TEMPLATE}_legacy_migrations").absolute() + +# This fails on windows CI because, when the test tries to execute +# `migrations.py`, it doesn't understand that it should be interpreted +# by python.exe. Or maybe it fails because CI is using Git bash instead +# of WSL bash, which happened to work fine in real world tests. +# FIXME Some generous Windows power user please fix this test! +@pytest.mark.xfail( + condition=platform.system() == "Windows", + reason="Windows ignores shebang?", + strict=True, +) +@pytest.mark.parametrize("skip_tasks", [True, False]) +def test_migrations_and_tasks(tmp_path: Path, skip_tasks: bool) -> None: + """Check migrations and tasks are run properly.""" + # Convert demo_migrations in a git repository with 2 versions + src, dst = tmp_path / "src", tmp_path / "dst" + copytree(SRC, src) + with local.cwd(src): + git("init") + git("config", "user.name", "Copier Test") + git("config", "user.email", "test@copier") + git("add", ".") + git("commit", "-m1") + git("tag", "v1.0.0") + git("commit", "--allow-empty", "-m2") + git("tag", "v2.0") + # Copy it in v1 + run_copy( + src_path=str(src), + dst_path=dst, + vcs_ref="v1.0.0", + unsafe=True, + skip_tasks=skip_tasks, + ) + # Check copy was OK + if skip_tasks: + assert not (dst / "created-with-tasks.txt").exists() + assert (dst / "delete-in-tasks.txt").exists() + else: + assert (dst / "created-with-tasks.txt").read_text() == "task 1\ntask 2\n" + assert not (dst / "delete-in-tasks.txt").exists() + assert (dst / "delete-in-migration-v2.txt").is_file() + assert not (dst / "migrations.py").exists() + assert not (dst / "tasks.py").exists() + assert not list(dst.glob("*-before.txt")) + assert not list(dst.glob("*-after.txt")) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers == {"_commit": "v1.0.0", "_src_path": str(src)} + # Save changes in downstream repo + with local.cwd(dst): + git("init") + git("add", ".") + git("config", "user.name", "Copier Test") + git("config", "user.email", "test@copier") + git("commit", "-m1") + # Update it to v2 + run_update( + dst_path=dst, defaults=True, overwrite=True, unsafe=True, skip_tasks=skip_tasks + ) + # Check update was OK + if skip_tasks: + assert not (dst / "created-with-tasks.txt").exists() + assert (dst / "delete-in-tasks.txt").exists() + else: + assert (dst / "created-with-tasks.txt").read_text() == "task 1\ntask 2\n" * 2 + assert not (dst / "delete-in-tasks.txt").exists() + assert not (dst / "delete-in-migration-v2.txt").exists() + assert not (dst / "migrations.py").exists() + assert not (dst / "tasks.py").exists() + assert (dst / "v1.0.0-v2-v2.0-before.json").is_file() + assert (dst / "v1.0.0-v2-v2.0-after.json").is_file() + assert (dst / "PEP440-1.0.0-2-2.0-before.json").is_file() + assert (dst / "PEP440-1.0.0-2-2.0-after.json").is_file() + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers == {"_commit": "v2.0", "_src_path": str(src)} + + +def test_pre_migration_modifies_answers( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Test support for answers modifications in pre-migrations.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + # v1 of template asks for a favourite song and writes it to songs.json + with local.cwd(src): + build_file_tree( + { + "[[ _copier_conf.answers_file ]].jinja": ( + "[[ _copier_answers|tojson ]]" + ), + "copier.yml": ( + f"""\ + _envops: {BRACKET_ENVOPS_JSON} + best_song: la vie en rose + """ + ), + "songs.json.jinja": "[ [[ best_song|tojson ]] ]", + } + ) + git("init") + git("add", ".") + git("commit", "-m1") + git("tag", "v1") + # User copies v1 template into subproject + with local.cwd(dst): + run_copy(src_path=str(src), defaults=True, overwrite=True) + answers = json.loads(Path(".copier-answers.yml").read_text()) + assert answers["_commit"] == "v1" + assert answers["best_song"] == "la vie en rose" + assert json.loads(Path("songs.json").read_text()) == ["la vie en rose"] + git("init") + git("add", ".") + git("commit", "-m1") + with local.cwd(src): + build_file_tree( + { + # v2 of template supports multiple songs, has a different default + # and includes a data format migration script + "copier.yml": ( + f"""\ + _envops: {BRACKET_ENVOPS_JSON} + best_song_list: + default: [paranoid android] + _migrations: + - version: v2 + before: + - - python + - -c + - | + import sys, json, pathlib + answers_path = pathlib.Path(*sys.argv[1:]) + answers = json.loads(answers_path.read_text()) + answers["best_song_list"] = [answers.pop("best_song")] + answers_path.write_text(json.dumps(answers)) + - "[[ _copier_conf.dst_path ]]" + - "[[ _copier_conf.answers_file ]]" + """ + ), + "songs.json.jinja": "[[ best_song_list|tojson ]]", + } + ) + git("add", ".") + git("commit", "-m2") + git("tag", "v2") + # User updates subproject to v2 template + with local.cwd(dst): + with pytest.deprecated_call(): + run_update(defaults=True, overwrite=True, unsafe=True) + answers = json.loads(Path(".copier-answers.yml").read_text()) + assert answers["_commit"] == "v2" + assert "best_song" not in answers + assert answers["best_song_list"] == ["la vie en rose"] + assert json.loads(Path("songs.json").read_text()) == ["la vie en rose"] + + +def test_prereleases(tmp_path_factory: pytest.TempPathFactory) -> None: + """Test prereleases support for copying and updating.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + with local.cwd(src): + # Build template in v1.0.0 + build_file_tree( + { + "version.txt": "v1.0.0", + "[[ _copier_conf.answers_file ]].jinja": "[[_copier_answers|to_nice_yaml]]", + "copier.yaml": ( + f"""\ + _envops: {BRACKET_ENVOPS_JSON} + _migrations: + - version: v1.9 + before: + - [python, -c, "import pathlib; pathlib.Path('v1.9').touch()"] + - version: v2.dev0 + before: + - [python, -c, "import pathlib; pathlib.Path('v2.dev0').touch()"] + - version: v2.dev2 + before: + - [python, -c, "import pathlib; pathlib.Path('v2.dev2').touch()"] + - version: v2.a1 + before: + - [python, -c, "import pathlib; pathlib.Path('v2.a1').touch()"] + - version: v2.a2 + before: + - [python, -c, "import pathlib; pathlib.Path('v2.a2').touch()"] + """ + ), + } + ) + git("init") + git("add", ".") + git("commit", "-mv1") + git("tag", "v1.0.0") + # Evolve template to v2.0.0.dev1 + build_file_tree({"version.txt": "v2.0.0.dev1"}) + git("commit", "-amv2dev1") + git("tag", "v2.0.0.dev1") + # Evolve template to v2.0.0.alpha1 + build_file_tree({"version.txt": "v2.0.0.alpha1"}) + git("commit", "-amv2a1") + git("tag", "v2.0.0.alpha1") + # Copying with use_prereleases=False copies v1 + run_copy(src_path=str(src), dst_path=dst, defaults=True, overwrite=True) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "v1.0.0" + assert (dst / "version.txt").read_text() == "v1.0.0" + assert not (dst / "v1.9").exists() + assert not (dst / "v2.dev0").exists() + assert not (dst / "v2.dev2").exists() + assert not (dst / "v2.a1").exists() + assert not (dst / "v2.a2").exists() + with local.cwd(dst): + # Commit subproject + git("init") + git("add", ".") + git("commit", "-mv1") + # Update it without prereleases; nothing changes + with pytest.deprecated_call(): + run_update(defaults=True, overwrite=True) + assert not git("status", "--porcelain") + assert not (dst / "v1.9").exists() + assert not (dst / "v2.dev0").exists() + assert not (dst / "v2.dev2").exists() + assert not (dst / "v2.a1").exists() + assert not (dst / "v2.a2").exists() + # Update it with prereleases + with pytest.deprecated_call(): + run_update( + dst_path=dst, defaults=True, overwrite=True, use_prereleases=True, unsafe=True + ) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "v2.0.0.alpha1" + assert (dst / "version.txt").read_text() == "v2.0.0.alpha1" + assert (dst / "v1.9").exists() + assert (dst / "v2.dev0").exists() + assert (dst / "v2.dev2").exists() + assert (dst / "v2.a1").exists() + assert not (dst / "v2.a2").exists() + # It should fail if downgrading + with pytest.raises(UserMessageError), pytest.deprecated_call(): + run_update(dst_path=dst, defaults=True, overwrite=True) + + +def test_pretend_mode(tmp_path_factory: pytest.TempPathFactory) -> None: + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + # Build template in v1 + with local.cwd(src): + git("init") + build_file_tree( + { + "[[ _copier_conf.answers_file ]].jinja": "[[_copier_answers|to_nice_yaml]]", + "copier.yml": ( + f"""\ + _envops: {BRACKET_ENVOPS_JSON} + """ + ), + } + ) + git("add", ".") + git("commit", "-mv1") + git("tag", "v1") + + run_copy(str(src), dst) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "v1" + + with local.cwd(dst): + git("init") + git("add", ".") + git("commit", "-mv1") + + # Evolve template to v2 + with local.cwd(src): + build_file_tree( + { + "[[ _copier_conf.answers_file ]].jinja": "[[_copier_answers|to_nice_yaml]]", + "copier.yml": ( + f"""\ + _envops: {BRACKET_ENVOPS_JSON} + _migrations: + - version: v2 + before: + - touch v2-before.txt + after: + - touch v2-after.txt + """ + ), + } + ) + git("add", ".") + git("commit", "-mv2") + git("tag", "v2") + + with pytest.deprecated_call(): + run_update(dst_path=dst, overwrite=True, pretend=True, unsafe=True) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "v1" + assert not (dst / "v2-before.txt").exists() + assert not (dst / "v2-after.txt").exists() diff --git a/tests/test_migrations.py b/tests/test_migrations.py index a134ec2f4..b0ed39762 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1,7 +1,4 @@ -import json -import platform from pathlib import Path -from shutil import copytree import pytest import yaml @@ -9,303 +6,518 @@ from plumbum.cmd import git from copier import run_copy, run_update -from copier.errors import UserMessageError +from copier.errors import UnsafeTemplateError, UserMessageError -from .helpers import BRACKET_ENVOPS_JSON, PROJECT_TEMPLATE, build_file_tree +from .helpers import ( + COPIER_ANSWERS_FILE, + PROJECT_TEMPLATE, + build_file_tree, + git_save, +) SRC = Path(f"{PROJECT_TEMPLATE}_migrations").absolute() -# This fails on windows CI because, when the test tries to execute -# `migrations.py`, it doesn't understand that it should be interpreted -# by python.exe. Or maybe it fails because CI is using Git bash instead -# of WSL bash, which happened to work fine in real world tests. -# FIXME Some generous Windows power user please fix this test! -@pytest.mark.xfail( - condition=platform.system() == "Windows", - reason="Windows ignores shebang?", - strict=True, -) -@pytest.mark.parametrize("skip_tasks", [True, False]) -def test_migrations_and_tasks(tmp_path: Path, skip_tasks: bool) -> None: - """Check migrations and tasks are run properly.""" - # Convert demo_migrations in a git repository with 2 versions - src, dst = tmp_path / "src", tmp_path / "dst" - copytree(SRC, src) +def test_basic_migration(tmp_path_factory: pytest.TempPathFactory) -> None: + """Test a basic migration running on every version""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + with local.cwd(src): - git("init") - git("config", "user.name", "Copier Test") - git("config", "user.email", "test@copier") - git("add", ".") - git("commit", "-m1") - git("tag", "v1.0.0") - git("commit", "--allow-empty", "-m2") - git("tag", "v2.0") - # Copy it in v1 - run_copy( - src_path=str(src), - dst_path=dst, - vcs_ref="v1.0.0", - unsafe=True, - skip_tasks=skip_tasks, - ) - # Check copy was OK - if skip_tasks: - assert not (dst / "created-with-tasks.txt").exists() - assert (dst / "delete-in-tasks.txt").exists() - else: - assert (dst / "created-with-tasks.txt").read_text() == "task 1\ntask 2\n" - assert not (dst / "delete-in-tasks.txt").exists() - assert (dst / "delete-in-migration-v2.txt").is_file() - assert not (dst / "migrations.py").exists() - assert not (dst / "tasks.py").exists() - assert not list(dst.glob("*-before.txt")) - assert not list(dst.glob("*-after.txt")) - answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) - assert answers == {"_commit": "v1.0.0", "_src_path": str(src)} - # Save changes in downstream repo + build_file_tree( + { + **COPIER_ANSWERS_FILE, + "copier.yml": ( + """\ + _migrations: + - touch foo + """ + ), + } + ) + git_save(tag="v1") with local.cwd(dst): - git("init") - git("add", ".") - git("config", "user.name", "Copier Test") - git("config", "user.email", "test@copier") - git("commit", "-m1") - # Update it to v2 - run_update( - dst_path=dst, defaults=True, overwrite=True, unsafe=True, skip_tasks=skip_tasks - ) - # Check update was OK - if skip_tasks: - assert not (dst / "created-with-tasks.txt").exists() - assert (dst / "delete-in-tasks.txt").exists() - else: - assert (dst / "created-with-tasks.txt").read_text() == "task 1\ntask 2\n" * 2 - assert not (dst / "delete-in-tasks.txt").exists() - assert not (dst / "delete-in-migration-v2.txt").exists() - assert not (dst / "migrations.py").exists() - assert not (dst / "tasks.py").exists() - assert (dst / "v1.0.0-v2-v2.0-before.json").is_file() - assert (dst / "v1.0.0-v2-v2.0-after.json").is_file() - assert (dst / "PEP440-1.0.0-2-2.0-before.json").is_file() - assert (dst / "PEP440-1.0.0-2-2.0-after.json").is_file() - answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) - assert answers == {"_commit": "v2.0", "_src_path": str(src)} + run_copy(src_path=str(src)) + git_save() + assert not (dst / "foo").exists() # Migrations don't run on initial copy -def test_pre_migration_modifies_answers( - tmp_path_factory: pytest.TempPathFactory, -) -> None: - """Test support for answers modifications in pre-migrations.""" + with local.cwd(src): + git("tag", "v2") + with local.cwd(dst): + run_update(defaults=True, overwrite=True, unsafe=True) + + assert (dst / "foo").is_file() + + +def test_requires_unsafe(tmp_path_factory: pytest.TempPathFactory) -> None: + """Tests that migrations require the unsafe flag to be passed""" src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) - # v1 of template asks for a favourite song and writes it to songs.json with local.cwd(src): build_file_tree( { - "[[ _copier_conf.answers_file ]].jinja": ( - "[[ _copier_answers|tojson ]]" - ), + **COPIER_ANSWERS_FILE, "copier.yml": ( - f"""\ - _envops: {BRACKET_ENVOPS_JSON} - best_song: la vie en rose + """\ + _migrations: + - touch foo """ ), - "songs.json.jinja": "[ [[ best_song|tojson ]] ]", } ) - git("init") - git("add", ".") - git("commit", "-m1") - git("tag", "v1") - # User copies v1 template into subproject + git_save(tag="v1") with local.cwd(dst): - run_copy(src_path=str(src), defaults=True, overwrite=True) - answers = json.loads(Path(".copier-answers.yml").read_text()) - assert answers["_commit"] == "v1" - assert answers["best_song"] == "la vie en rose" - assert json.loads(Path("songs.json").read_text()) == ["la vie en rose"] - git("init") - git("add", ".") - git("commit", "-m1") + run_copy(src_path=str(src)) + git_save() + + assert not (dst / "foo").exists() # Migrations don't run on initial copy + + with local.cwd(src): + git("tag", "v2") + with local.cwd(dst): + with pytest.raises(UnsafeTemplateError): + run_update(defaults=True, overwrite=True, unsafe=False) + + assert not (dst / "foo").exists() + + +def test_version_migration(tmp_path_factory: pytest.TempPathFactory) -> None: + """Test a migration running on a specific version""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + with local.cwd(src): build_file_tree( { - # v2 of template supports multiple songs, has a different default - # and includes a data format migration script + **COPIER_ANSWERS_FILE, "copier.yml": ( - f"""\ - _envops: {BRACKET_ENVOPS_JSON} - best_song_list: - default: [paranoid android] + """\ _migrations: - - version: v2 - before: - - - python - - -c - - | - import sys, json, pathlib - answers_path = pathlib.Path(*sys.argv[1:]) - answers = json.loads(answers_path.read_text()) - answers["best_song_list"] = [answers.pop("best_song")] - answers_path.write_text(json.dumps(answers)) - - "[[ _copier_conf.dst_path ]]" - - "[[ _copier_conf.answers_file ]]" + - version: v3 + command: touch foo """ ), - "songs.json.jinja": "[[ best_song_list|tojson ]]", } ) - git("add", ".") - git("commit", "-m2") - git("tag", "v2") - # User updates subproject to v2 template + git_save(tag="v1") with local.cwd(dst): - run_update(defaults=True, overwrite=True, unsafe=True) - answers = json.loads(Path(".copier-answers.yml").read_text()) - assert answers["_commit"] == "v2" - assert "best_song" not in answers - assert answers["best_song_list"] == ["la vie en rose"] - assert json.loads(Path("songs.json").read_text()) == ["la vie en rose"] + run_copy(src_path=str(src)) + + assert not (dst / "foo").exists() # Migrations don't run on initial copy + for i in range(2, 5): + with local.cwd(src): + git_save(tag=f"v{i}", allow_empty=True) + with local.cwd(dst): + git_save() + run_update(defaults=True, overwrite=True, unsafe=True) -def test_prereleases(tmp_path_factory: pytest.TempPathFactory) -> None: - """Test prereleases support for copying and updating.""" + if i == 3: + assert (dst / "foo").is_file() + (dst / "foo").unlink() + else: + assert not (dst / "foo").exists() + + +def test_prerelease_version_migration(tmp_path_factory: pytest.TempPathFactory) -> None: + """Test if prerelease version migrations work""" src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + versions = ["v2.dev0", "v2.dev2", "v2.a1", "v2.a2"] + with local.cwd(src): - # Build template in v1.0.0 build_file_tree( { - "version.txt": "v1.0.0", - "[[ _copier_conf.answers_file ]].jinja": "[[_copier_answers|to_nice_yaml]]", - "copier.yaml": ( - f"""\ - _envops: {BRACKET_ENVOPS_JSON} + **COPIER_ANSWERS_FILE, + "copier.yml": ( + """\ _migrations: - version: v1.9 - before: - - [python, -c, "import pathlib; pathlib.Path('v1.9').touch()"] + command: touch v1.9 - version: v2.dev0 - before: - - [python, -c, "import pathlib; pathlib.Path('v2.dev0').touch()"] + command: touch v2.dev0 - version: v2.dev2 - before: - - [python, -c, "import pathlib; pathlib.Path('v2.dev2').touch()"] + command: touch v2.dev2 - version: v2.a1 - before: - - [python, -c, "import pathlib; pathlib.Path('v2.a1').touch()"] + command: touch v2.a1 - version: v2.a2 - before: - - [python, -c, "import pathlib; pathlib.Path('v2.a2').touch()"] + command: touch v2.a2 """ ), } ) - git("init") - git("add", ".") - git("commit", "-mv1") - git("tag", "v1.0.0") - # Evolve template to v2.0.0.dev1 - build_file_tree({"version.txt": "v2.0.0.dev1"}) - git("commit", "-amv2dev1") - git("tag", "v2.0.0.dev1") - # Evolve template to v2.0.0.alpha1 - build_file_tree({"version.txt": "v2.0.0.alpha1"}) - git("commit", "-amv2a1") - git("tag", "v2.0.0.alpha1") - # Copying with use_prereleases=False copies v1 - run_copy(src_path=str(src), dst_path=dst, defaults=True, overwrite=True) - answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) - assert answers["_commit"] == "v1.0.0" - assert (dst / "version.txt").read_text() == "v1.0.0" - assert not (dst / "v1.9").exists() - assert not (dst / "v2.dev0").exists() - assert not (dst / "v2.dev2").exists() - assert not (dst / "v2.a1").exists() - assert not (dst / "v2.a2").exists() + git_save(tag="v1") with local.cwd(dst): - # Commit subproject - git("init") - git("add", ".") - git("commit", "-mv1") - # Update it without prereleases; nothing changes - run_update(defaults=True, overwrite=True) - assert not git("status", "--porcelain") + run_copy(src_path=str(src)) + with local.cwd(src): + for version in ["v1.9", *versions]: + git_save(tag=version, allow_empty=True) + assert not (dst / "v1.9").exists() - assert not (dst / "v2.dev0").exists() - assert not (dst / "v2.dev2").exists() - assert not (dst / "v2.a1").exists() - assert not (dst / "v2.a2").exists() - # Update it with prereleases - run_update( - dst_path=dst, defaults=True, overwrite=True, use_prereleases=True, unsafe=True - ) + assert all(not (dst / version).exists() for version in versions) + + with local.cwd(dst): + git_save() + # No pre-releases. Should update to v1.9 + run_update(defaults=True, overwrite=True, unsafe=True) + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) - assert answers["_commit"] == "v2.0.0.alpha1" - assert (dst / "version.txt").read_text() == "v2.0.0.alpha1" + assert answers["_commit"] == "v1.9" assert (dst / "v1.9").exists() - assert (dst / "v2.dev0").exists() - assert (dst / "v2.dev2").exists() - assert (dst / "v2.a1").exists() - assert not (dst / "v2.a2").exists() - # It should fail if downgrading - with pytest.raises(UserMessageError): - run_update(dst_path=dst, defaults=True, overwrite=True) + assert all(not (dst / version).exists() for version in versions) + with local.cwd(dst): + git_save() + # With pre-releases. Should update to v2.a2 + run_update(defaults=True, overwrite=True, unsafe=True, use_prereleases=True) + + answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + assert answers["_commit"] == "v2.a2" + assert (dst / "v1.9").exists() + assert all((dst / version).exists() for version in versions) + + with local.cwd(dst): + git_save() + with pytest.raises(UserMessageError): + # Can't downgrade + run_update(defaults=True, overwrite=True, unsafe=True) -def test_pretend_mode(tmp_path_factory: pytest.TempPathFactory) -> None: - src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) - # Build template in v1 +def test_migration_working_directory(tmp_path_factory: pytest.TempPathFactory) -> None: + """Test the working directory attribute of migrations""" + src, dst, workdir = map(tmp_path_factory.mktemp, ("src", "dst", "workdir")) + with local.cwd(src): - git("init") build_file_tree( { - "[[ _copier_conf.answers_file ]].jinja": "[[_copier_answers|to_nice_yaml]]", + **COPIER_ANSWERS_FILE, "copier.yml": ( f"""\ - _envops: {BRACKET_ENVOPS_JSON} + _migrations: + - command: touch foo + working_directory: {workdir} """ ), } ) - git("add", ".") - git("commit", "-mv1") - git("tag", "v1") + git_save(tag="v1") + with local.cwd(dst): + run_copy(src_path=str(src)) + git_save() - run_copy(str(src), dst) - answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) - assert answers["_commit"] == "v1" + assert not (workdir / "foo").exists() # Migrations don't run on initial copy + with local.cwd(src): + git("tag", "v2") with local.cwd(dst): - git("init") - git("add", ".") - git("commit", "-mv1") + run_update(defaults=True, overwrite=True, unsafe=True) + + assert not (dst / "foo").exists() + assert (workdir / "foo").is_file() + + +@pytest.mark.parametrize("condition", (True, False)) +def test_migration_condition( + tmp_path_factory: pytest.TempPathFactory, condition: bool +) -> None: + """Test the `when` argument of migrations""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) - # Evolve template to v2 with local.cwd(src): build_file_tree( { - "[[ _copier_conf.answers_file ]].jinja": "[[_copier_answers|to_nice_yaml]]", + **COPIER_ANSWERS_FILE, "copier.yml": ( f"""\ - _envops: {BRACKET_ENVOPS_JSON} _migrations: - - version: v2 - before: - - touch v2-before.txt - after: - - touch v2-after.txt + - command: touch foo + when: {'true' if condition else 'false'} """ ), } ) - git("add", ".") - git("commit", "-mv2") + git_save(tag="v1") + with local.cwd(dst): + run_copy(src_path=str(src)) + git_save() + + assert not (dst / "foo").exists() # Migrations don't run on initial copy + + with local.cwd(src): git("tag", "v2") + with local.cwd(dst): + run_update(defaults=True, overwrite=True, unsafe=True) - run_update(dst_path=dst, overwrite=True, pretend=True, unsafe=True) - answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) - assert answers["_commit"] == "v1" - assert not (dst / "v2-before.txt").exists() - assert not (dst / "v2-after.txt").exists() + assert (dst / "foo").is_file() == condition + + +def test_pretend_migration(tmp_path_factory: pytest.TempPathFactory) -> None: + """Test that migrations aren't run in pretend mode""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + with local.cwd(src): + build_file_tree( + { + **COPIER_ANSWERS_FILE, + "copier.yml": ( + """\ + _migrations: + - touch foo + """ + ), + } + ) + git_save(tag="v1") + with local.cwd(dst): + run_copy(src_path=str(src)) + git_save() + + assert not (dst / "foo").exists() # Migrations don't run on initial copy + + with local.cwd(src): + git("tag", "v2") + with local.cwd(dst): + run_update(defaults=True, overwrite=True, unsafe=True, pretend=True) + + assert not (dst / "foo").exists() # In pretend mode the command shouldn't run + + +def test_skip_migration(tmp_path_factory: pytest.TempPathFactory) -> None: + """Test that migrations aren't run in pretend mode""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + with local.cwd(src): + build_file_tree( + { + **COPIER_ANSWERS_FILE, + "copier.yml": ( + """\ + _migrations: + - touch foo + """ + ), + } + ) + git_save(tag="v1") + with local.cwd(dst): + run_copy(src_path=str(src)) + git_save() + + assert not (dst / "foo").exists() # Migrations don't run on initial copy + + with local.cwd(src): + git("tag", "v2") + with local.cwd(dst): + run_update(defaults=True, overwrite=True, unsafe=True, skip_tasks=True) + + assert (dst / "foo").exists() # Migrations are not skipped by skip_tasks + + +def test_migration_run_before(tmp_path_factory: pytest.TempPathFactory) -> None: + """Test running migrations in the before upgrade step""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + with local.cwd(src): + build_file_tree( + { + **COPIER_ANSWERS_FILE, + # We replace an answer in the before step, so it will be used during update + "copier.yml": ( + """ + hello: + default: World + _migrations: + - command: + - "{{ _copier_python }}" + - -c + - | + import yaml + with open(".copier-answers.yml", "r") as f: + v = yaml.safe_load(f) + v["hello"] = "Copier" + with open(".copier-answers.yml", "w") as f: + yaml.safe_dump(v, f) + when: \"{{ _stage == 'before' }}\" + """ + ), + "foo.jinja": "Hello {{ hello }}", + } + ) + git_save(tag="v1") + with local.cwd(dst): + run_copy(src_path=str(src), defaults=True) + git_save() + + assert (dst / "foo").is_file() + assert (dst / "foo").read_text() == "Hello World" + + with local.cwd(src): + build_file_tree({"foo": ""}) + git_save(tag="v2") + with local.cwd(dst): + run_update(defaults=True, overwrite=True, unsafe=True) + + assert (dst / "foo").is_file() + assert (dst / "foo").read_text() == "Hello Copier" + + +@pytest.mark.parametrize("explicit", (True, False)) +def test_migration_run_after( + tmp_path_factory: pytest.TempPathFactory, explicit: bool +) -> None: + """ + Test running migrations in the before upgrade step + Also checks that this is the default behaviour if no `when` is given. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + with local.cwd(src): + build_file_tree( + { + **COPIER_ANSWERS_FILE, + "copier.yml": ( + # Python < 3.11 don't support escapes in f-string expressions, so + # we use .format instead + """\ + _migrations: + - command: mv foo bar + {} + """.format("when: \"{{ _stage == 'after' }}\"" if explicit else "") + ), + } + ) + git_save(tag="v1") + with local.cwd(dst): + run_copy(src_path=str(src)) + git_save() + + with local.cwd(src): + build_file_tree({"foo": ""}) + git_save(tag="v2") + with local.cwd(dst): + run_update(defaults=True, overwrite=True, unsafe=True) + + assert not (dst / "foo").exists() + assert (dst / "bar").is_file() + + +@pytest.mark.parametrize("with_version", [True, False]) +def test_migration_env_variables( + tmp_path_factory: pytest.TempPathFactory, with_version: bool +) -> None: + """Test that environment variables are passed to the migration commands""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + variables = { + "STAGE": "after", + "VERSION_FROM": "v1", + "VERSION_TO": "v3", + "VERSION_PEP440_FROM": "1", + "VERSION_PEP440_TO": "3", + } + current_only_variables = { + "VERSION_CURRENT": "v2", + "VERSION_PEP440_CURRENT": "2", + } + + with local.cwd(src): + build_file_tree( + { + **COPIER_ANSWERS_FILE, + "copier.yml": ( + f"""\ + _migrations: + - command: env > env.txt + {"version: v2" if with_version else ""} + """ + ), + } + ) + git_save(tag="v1") + with local.cwd(dst): + run_copy(src_path=str(src)) + git_save() + + assert not (dst / "foo").exists() # Migrations don't run on initial copy + + with local.cwd(src): + build_file_tree({"version": "v2"}) + git_save(tag="v2") + with local.cwd(src): + build_file_tree({"version": "v3"}) + git_save(tag="v3") + with local.cwd(dst): + run_update(defaults=True, overwrite=True, unsafe=True) + + assert (dst / "env.txt").is_file() + env = (dst / "env.txt").read_text().split("\n") + for variable, value in variables.items(): + assert f"{variable}={value}" in env + + for variable, value in current_only_variables.items(): + assert (f"{variable}={value}" in env) == with_version + + +@pytest.mark.parametrize("with_version", [True, False]) +def test_migration_jinja_variables( + tmp_path_factory: pytest.TempPathFactory, with_version: bool +) -> None: + """Test that environment variables are passed to the migration commands""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + variables = { + "_stage": "after", + "_version_from": "v1", + "_version_to": "v3", + "_version_pep440_from": "1", + "_version_pep440_to": "3", + } + current_only_variables = { + "_version_current": "v2", + "_version_pep440_current": "2", + } + all_variables = {**variables, **current_only_variables} + + command = "&&".join( + f"echo {var}={{{{ {var} }}}} >> vars.txt" for var in all_variables + ) + + with local.cwd(src): + build_file_tree( + { + **COPIER_ANSWERS_FILE, + "copier.yml": ( + f"""\ + _migrations: + - command: {command} + {"version: v2" if with_version else ""} + """ + ), + } + ) + git_save(tag="v1") + with local.cwd(dst): + run_copy(src_path=str(src)) + git_save() + + assert not (dst / "foo").exists() # Migrations don't run on initial copy + + with local.cwd(src): + build_file_tree({"version": "v2"}) + git_save(tag="v2") + with local.cwd(src): + build_file_tree({"version": "v3"}) + git_save(tag="v3") + with local.cwd(dst): + run_update(defaults=True, overwrite=True, unsafe=True) + + assert (dst / "vars.txt").is_file() + raw_vars = (dst / "vars.txt").read_text().split("\n") + vars = map(lambda x: x.strip(), raw_vars) + for variable, value in variables.items(): + assert f"{variable}={value}" in vars + + for variable, value in current_only_variables.items(): + if with_version: + assert f"{variable}={value}" in vars + else: + assert f"{variable}=" in vars diff --git a/tests/test_tasks.py b/tests/test_tasks.py index e60a75dde..fb00027ad 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -21,15 +21,21 @@ def template_path(tmp_path_factory: pytest.TempPathFactory) -> str: _envops: {BRACKET_ENVOPS_JSON} other_file: bye + condition: true # This tests two things: # 1. That the tasks are being executed in the destination folder; and # 2. That the tasks are being executed in order, one after another _tasks: - - mkdir hello - - cd hello && touch world - - touch [[ other_file ]] - - ["[[ _copier_python ]]", "-c", "open('pyfile', 'w').close()"] + - mkdir hello + - command: touch world + working_directory: ./hello + - touch [[ other_file ]] + - ["[[ _copier_python ]]", "-c", "open('pyfile', 'w').close()"] + - command: touch true + when: "[[ condition ]]" + - command: touch false + when: "[[ not condition ]]" """ ) } @@ -38,7 +44,12 @@ def template_path(tmp_path_factory: pytest.TempPathFactory) -> str: def test_render_tasks(template_path: str, tmp_path: Path) -> None: - copier.run_copy(template_path, tmp_path, data={"other_file": "custom"}, unsafe=True) + copier.run_copy( + template_path, + tmp_path, + data={"other_file": "custom", "condition": "true"}, + unsafe=True, + ) assert (tmp_path / "custom").is_file() @@ -51,6 +62,8 @@ def test_copy_tasks(template_path: str, tmp_path: Path) -> None: assert (tmp_path / "hello" / "world").exists() assert (tmp_path / "bye").is_file() assert (tmp_path / "pyfile").is_file() + assert (tmp_path / "true").is_file() + assert not (tmp_path / "false").exists() def test_copy_skip_tasks(template_path: str, tmp_path: Path) -> None: