Add migrations support

This commit fixes #119. Summary:

- Depend on packaging to get a good implementation of PEP 440.
- Document new behaviors.
- Add support for migrations.
- Checkout by default the most modern git tag available in the template.
- Reuse `run_tasks` to run migrations too.
- Use `git describe --tags --always` to obtain template commit, to get a version parseable by PEP 440.
- Update `test_updatediff` to with new behaviors.

@Tecnativa TT20357
This commit is contained in:
Jairo Llopis 2020-02-21 13:52:35 +00:00
parent 9e515ed9f3
commit 6bf0cedd3a
No known key found for this signature in database
GPG Key ID: 0CFC348F9B242B08
13 changed files with 152 additions and 44 deletions

View File

@ -193,6 +193,18 @@ _tasks:
- "git init"
- "rm [[ name_of_the_project ]]/README.md"
# Migrations are like tasks, but they are executed:
# - Evaluated using PEP 440
# - In the same order as declared here
# - Only when new version >= declared version > old version
# - Only when updating
_migrations:
- version: v1.0.0
before:
- rm ./old-folder
after:
- pre-commit install
# Additional paths, from where to search for templates
_extra_paths:
- ~/Projects/templates
@ -232,7 +244,7 @@ should match questions in `copier.yml`.
The best way to update a project from its template is when all of these conditions are true:
1. The template includes a valid `.copier-answers.yml` file.
1. The template is versioned with git.
1. The template is versioned with git (with tags).
1. The destination folder is versioned with git.
If that's your case, then just enter the destination folder, make sure
@ -242,6 +254,8 @@ If that's your case, then just enter the destination folder, make sure
copier update
```
This will read all available git tags, will compare them using [PEP 440](https://www.python.org/dev/peps/pep-0440/), and will checkout the latest one before updating. To update to the latest commit, add `--vcs-ref=HEAD`. You can use any other git ref you want.
Copier will do its best to respect the answers you provided when copied for the last
copy, and the git diff that has evolved since the last copy. If there are conflicts,
you will probably find diff files around.

View File

@ -67,9 +67,15 @@ class CopierApp(cli.Application):
),
)
vcs_ref = cli.SwitchAttr(
["-r", "--vcs-ref"], str, help="Git reference to checkout in `template_src`"
["-r", "--vcs-ref"],
str,
help=(
"Git reference to checkout in `template_src`. "
"If you do not specify it, it will try to checkout the latest git tag, "
"as sorted using the PEP 440 algorithm. If you want to checkout always "
"the latest version, use `--vcs-ref=HEAD`.",
),
)
pretend = cli.Flag(["-n", "--pretend"], help="Run but do not make any changes")
force = cli.Flag(
["-f", "--force"], help="Overwrite files that already exist, without asking"

View File

@ -42,7 +42,7 @@ def make_config(
skip: OptBool = None,
quiet: OptBool = None,
cleanup_on_error: OptBool = None,
vcs_ref: str = "HEAD",
vcs_ref: OptStr = None,
**kwargs,
) -> ConfigData:
"""Provides the configuration object, merged from the different sources.
@ -71,9 +71,10 @@ def make_config(
if src_path:
repo = vcs.get_repo(src_path)
if repo:
src_path = vcs.clone(repo, vcs_ref)
src_path = vcs.clone(repo, vcs_ref or "HEAD")
vcs_ref = vcs_ref or vcs.checkout_latest_tag(src_path)
with local.cwd(src_path):
_metadata["commit"] = git("rev-parse", "HEAD").strip()
_metadata["commit"] = git("describe", "--tags", "--always").strip()
# Obtain config and query data, asking the user if needed
file_data = load_config_data(src_path, quiet=True)
config_data, questions_data = filter_config(file_data)

View File

@ -2,7 +2,7 @@ import datetime
from hashlib import sha512
from os import urandom
from pathlib import Path
from typing import Any, Tuple
from typing import Any, Sequence, Tuple
from pydantic import BaseModel, Extra, StrictBool, validator
@ -55,15 +55,21 @@ class EnvOps(BaseModel):
extra = Extra.allow
class Migrations(BaseModel):
version: str
before: StrSeq = ()
after: StrSeq = ()
class ConfigData(BaseModel):
src_path: Path
dst_path: Path
data: AnyByStrDict = {}
extra_paths: PathSeq = []
extra_paths: PathSeq = ()
exclude: StrOrPathSeq = DEFAULT_EXCLUDE
include: StrOrPathSeq = []
skip_if_exists: StrOrPathSeq = []
tasks: StrSeq = []
include: StrOrPathSeq = ()
skip_if_exists: StrOrPathSeq = ()
tasks: StrSeq = ()
envops: EnvOps = EnvOps()
templates_suffix: str = DEFAULT_TEMPLATES_SUFFIX
original_src_path: OptStr
@ -76,6 +82,7 @@ class ConfigData(BaseModel):
quiet: StrictBool = False
skip: StrictBool = False
vcs_ref: OptStr
migrations: Sequence[Migrations] = ()
def __init__(self, **data: Any):
super().__init__(**data)

View File

@ -13,7 +13,15 @@ from plumbum.cmd import git
from . import vcs
from .config import make_config
from .config.objects import ConfigData, UserMessageError
from .tools import Renderer, Style, copy_file, get_name_filters, make_folder, printf
from .tools import (
Renderer,
Style,
copy_file,
get_migration_tasks,
get_name_filters,
make_folder,
printf,
)
from .types import (
AnyByStrDict,
CheckPathFunc,
@ -43,7 +51,7 @@ def copy(
skip: OptBool = False,
quiet: OptBool = False,
cleanup_on_error: OptBool = True,
vcs_ref: str = "HEAD",
vcs_ref: OptStr = None,
only_diff: OptBool = True,
) -> None:
"""
@ -167,10 +175,9 @@ def copy_local(conf: ConfigData) -> None:
if not conf.quiet:
print("") # padding space
if conf.tasks:
run_tasks(conf, render)
if not conf.quiet:
print("") # padding space
run_tasks(conf, render, conf.tasks)
if not conf.quiet:
print("") # padding space
def update_diff(conf: ConfigData):
@ -207,11 +214,16 @@ def update_diff(conf: ConfigData):
git("remote", "add", "real_dst", conf.dst_path)
git("fetch", "real_dst", "HEAD")
diff = git("diff", "--unified=0", "HEAD...FETCH_HEAD")
# Run pre-migration tasks
renderer = Renderer(conf)
run_tasks(conf, renderer, get_migration_tasks(conf, "before"))
# Do a normal update in final destination
copy_local(conf)
# Try to apply cached diff into final destination
with local.cwd(conf.dst_path):
(git["apply", "--reject"] << diff)(retcode=None)
# Run post-migration tasks
run_tasks(conf, renderer, get_migration_tasks(conf, "after"))
def get_source_paths(
@ -310,9 +322,9 @@ def overwrite_file(conf: ConfigData, dst_path: Path, rel_path: Path) -> bool:
return bool(ask(f" Overwrite {dst_path}?", default=True))
def run_tasks(conf: ConfigData, render: Renderer) -> None:
for i, task in enumerate(conf.tasks):
def run_tasks(conf: ConfigData, render: Renderer, tasks: StrSeq) -> None:
for i, task in enumerate(tasks):
task = render.string(task)
# TODO: should we respect the `quiet` flag here as well?
printf(f" > Running task {i + 1} of {len(conf.tasks)}", task, style=Style.OK)
printf(f" > Running task {i + 1} of {len(tasks)}", task, style=Style.OK)
subprocess.run(task, shell=True, check=True, cwd=conf.dst_path)

View File

@ -10,11 +10,20 @@ from typing import Any, Optional, Sequence, Tuple, Union
import colorama
from jinja2 import FileSystemLoader
from jinja2.sandbox import SandboxedEnvironment
from packaging import version
from pydantic import StrictBool
from ruamel.yaml import round_trip_dump
from .config.objects import ConfigData, EnvOps
from .types import AnyByStrDict, CheckPathFunc, IntSeq, JSONSerializable, StrOrPath, T
from .types import (
AnyByStrDict,
CheckPathFunc,
IntSeq,
JSONSerializable,
StrOrPath,
StrSeq,
T,
)
__all__ = ("Style", "printf")
@ -173,3 +182,19 @@ def get_name_filters(
return must_be_filtered(path) and not must_be_included(path)
return must_filter, must_skip
def get_migration_tasks(conf: ConfigData, stage: str) -> StrSeq:
"""Get migration objects that match current version spec.
Versions are compared using PEP 440.
"""
result: StrSeq = []
if not conf.old_commit or not conf.commit:
return result
vfrom = version.parse(conf.old_commit)
vto = version.parse(conf.commit)
for migration in conf.migrations:
if vto >= version.parse(migration.version) > vfrom:
result += migration.dict().get(stage, [])
return result

View File

@ -3,10 +3,11 @@ import shutil
import tempfile
from pathlib import Path
from plumbum import TF, local
from packaging import version
from plumbum import TF, colors, local
from plumbum.cmd import git
from .types import OptStr
from .types import OptStr, StrOrPath
__all__ = ("get_repo", "clone")
@ -52,6 +53,21 @@ def get_repo(url: str) -> OptStr:
return url
def checkout_latest_tag(local_repo: StrOrPath) -> str:
"""Checkout latest git tag and check it out, sorted by PEP 440."""
with local.cwd(local_repo):
all_tags = git("tag").split()
sorted_tags = sorted(all_tags, key=version.parse, reverse=True)
try:
latest_tag = str(sorted_tags[0])
except IndexError:
print(colors.warn | "No git tags found in template; using HEAD as ref")
latest_tag = "HEAD"
git("checkout", "--force", latest_tag)
git("submodule", "update", "--checkout", "--init", "--recursive", "--force")
return latest_tag
def clone(url: str, ref: str = "HEAD") -> str:
location = tempfile.mkdtemp()
shutil.rmtree(location) # Path must not exist

View File

@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
combine_as_imports = true
force_grid_wrap = 0
include_trailing_comma = true
known_third_party = ["colorama", "jinja2", "pkg_resources", "plumbum", "pydantic", "pytest", "ruamel", "setuptools", "six"]
known_third_party = ["colorama", "jinja2", "packaging", "pkg_resources", "plumbum", "pydantic", "pytest", "ruamel", "setuptools", "six"]
line_length = 88
multi_line_output = 3
use_parentheses = true

View File

@ -28,6 +28,7 @@ install_requires =
ruamel.yaml ~= 0.15
plumbum ~= 1.6
pydantic >= 1.0b1
packaging ~= 20.1
[options.packages.find]
exclude =

Binary file not shown.

View File

@ -175,6 +175,7 @@ def test_config_data_good_data(dst):
"quiet": False,
"skip": False,
"vcs_ref": None,
"migrations": (),
}
conf = ConfigData(**expected)
expected["data"]["_folder_name"] = dst.name
@ -214,5 +215,5 @@ def test_make_config_good_data(dst):
],
)
def test_make_config_precedence(dst, test_input, expected):
conf = make_config(dst_path=dst, **test_input)
conf = make_config(dst_path=dst, vcs_ref="HEAD", **test_input)
assert is_subdict(expected, conf.dict())

View File

@ -50,7 +50,7 @@ def test_copy(dst):
def test_copy_repo(dst):
copier.copy("gh:jpscaletti/siht.git", dst, quiet=True)
copier.copy("gh:jpscaletti/siht.git", dst, vcs_ref="HEAD", quiet=True)
assert (dst / "setup.py").exists()

View File

@ -8,8 +8,6 @@ from copier.cli import CopierApp
from .helpers import PROJECT_TEMPLATE
COMMIT_1 = "49deace1b66f3a88a6305cc380d7596cc8170dc9"
COMMIT_2 = "c2ac5c45404cbd9b031acebcf398f19f56ce49dc"
REPO_BUNDLE_PATH = Path(f"{PROJECT_TEMPLATE}_updatediff_repo.bundle").absolute()
@ -18,15 +16,15 @@ def test_updatediff(dst: Path):
readme = target / "README.txt"
answers = target / ".copier-answers.yml"
commit = git["commit", "--all", "--author", "Copier Test <test@copier>"]
# Run copier 1st time, with specific commit
# Run copier 1st time, with specific tag
CopierApp.invoke(
"copy", str(REPO_BUNDLE_PATH), str(target), force=True, vcs_ref=COMMIT_1
"copy", str(REPO_BUNDLE_PATH), str(target), force=True, vcs_ref="v0.0.1"
)
# Check it's copied OK
assert answers.read_text() == dedent(
f"""
# Changes here will be overwritten by Copier
_commit: {COMMIT_1}
_commit: v0.0.1
_src_path: {REPO_BUNDLE_PATH}
author_name: Guybrush
project_name: to become a pirate
@ -46,28 +44,55 @@ def test_updatediff(dst: Path):
git("init")
git("add", ".")
commit("-m", "hello world")
# Emulate the user modifying the README by hand
with open(readme, "w") as readme_fd:
readme_fd.write(
dedent(
"""
Let me introduce myself.
# Emulate the user modifying the README by hand
with open(readme, "w") as readme_fd:
readme_fd.write(
dedent(
"""
Let me introduce myself.
My name is Guybrush, and my project is to become a pirate.
My name is Guybrush, and my project is to become a pirate.
Thanks for your grog.
"""
Thanks for your grog.
"""
)
)
)
with local.cwd(target):
commit("-m", "I prefer grog")
# Update target to latest commit
# Update target to latest tag and check it's updated in answers file
CopierApp.invoke(force=True)
assert answers.read_text() == dedent(
f"""
# Changes here will be overwritten by Copier
_commit: v0.0.2
_src_path: {REPO_BUNDLE_PATH}
author_name: Guybrush
project_name: to become a pirate
"""
)
# Check migrations were executed properly
assert not (target / "before-v0.0.1").is_file()
assert not (target / "after-v0.0.1").is_file()
assert (target / "before-v0.0.2").is_file()
assert (target / "after-v0.0.2").is_file()
(target / "before-v0.0.2").unlink()
(target / "after-v0.0.2").unlink()
assert not (target / "before-v1.0.0").is_file()
assert not (target / "after-v1.0.0").is_file()
commit("-m", "Update template to v0.0.2")
# Update target to latest commit, which is still untagged
CopierApp.invoke(force=True, vcs_ref="HEAD")
# Check no new migrations were executed
assert not (target / "before-v0.0.1").is_file()
assert not (target / "after-v0.0.1").is_file()
assert not (target / "before-v0.0.2").is_file()
assert not (target / "after-v0.0.2").is_file()
assert not (target / "before-v1.0.0").is_file()
assert not (target / "after-v1.0.0").is_file()
# Check it's updated OK
assert answers.read_text() == dedent(
f"""
# Changes here will be overwritten by Copier
_commit: {COMMIT_2}
_commit: v0.0.2-1-g81c335d
_src_path: {REPO_BUNDLE_PATH}
author_name: Guybrush
project_name: to become a pirate