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" - "git init"
- "rm [[ name_of_the_project ]]/README.md" - "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 # Additional paths, from where to search for templates
_extra_paths: _extra_paths:
- ~/Projects/templates - ~/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: 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 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. 1. The destination folder is versioned with git.
If that's your case, then just enter the destination folder, make sure 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 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 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, copy, and the git diff that has evolved since the last copy. If there are conflicts,
you will probably find diff files around. you will probably find diff files around.

View File

@ -67,9 +67,15 @@ class CopierApp(cli.Application):
), ),
) )
vcs_ref = cli.SwitchAttr( 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") pretend = cli.Flag(["-n", "--pretend"], help="Run but do not make any changes")
force = cli.Flag( force = cli.Flag(
["-f", "--force"], help="Overwrite files that already exist, without asking" ["-f", "--force"], help="Overwrite files that already exist, without asking"

View File

@ -42,7 +42,7 @@ def make_config(
skip: OptBool = None, skip: OptBool = None,
quiet: OptBool = None, quiet: OptBool = None,
cleanup_on_error: OptBool = None, cleanup_on_error: OptBool = None,
vcs_ref: str = "HEAD", vcs_ref: OptStr = None,
**kwargs, **kwargs,
) -> ConfigData: ) -> ConfigData:
"""Provides the configuration object, merged from the different sources. """Provides the configuration object, merged from the different sources.
@ -71,9 +71,10 @@ def make_config(
if src_path: if src_path:
repo = vcs.get_repo(src_path) repo = vcs.get_repo(src_path)
if repo: 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): 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 # Obtain config and query data, asking the user if needed
file_data = load_config_data(src_path, quiet=True) file_data = load_config_data(src_path, quiet=True)
config_data, questions_data = filter_config(file_data) config_data, questions_data = filter_config(file_data)

View File

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

View File

@ -13,7 +13,15 @@ from plumbum.cmd import git
from . import vcs from . import vcs
from .config import make_config from .config import make_config
from .config.objects import ConfigData, UserMessageError 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 ( from .types import (
AnyByStrDict, AnyByStrDict,
CheckPathFunc, CheckPathFunc,
@ -43,7 +51,7 @@ def copy(
skip: OptBool = False, skip: OptBool = False,
quiet: OptBool = False, quiet: OptBool = False,
cleanup_on_error: OptBool = True, cleanup_on_error: OptBool = True,
vcs_ref: str = "HEAD", vcs_ref: OptStr = None,
only_diff: OptBool = True, only_diff: OptBool = True,
) -> None: ) -> None:
""" """
@ -167,10 +175,9 @@ def copy_local(conf: ConfigData) -> None:
if not conf.quiet: if not conf.quiet:
print("") # padding space print("") # padding space
if conf.tasks: run_tasks(conf, render, conf.tasks)
run_tasks(conf, render) if not conf.quiet:
if not conf.quiet: print("") # padding space
print("") # padding space
def update_diff(conf: ConfigData): def update_diff(conf: ConfigData):
@ -207,11 +214,16 @@ def update_diff(conf: ConfigData):
git("remote", "add", "real_dst", conf.dst_path) git("remote", "add", "real_dst", conf.dst_path)
git("fetch", "real_dst", "HEAD") git("fetch", "real_dst", "HEAD")
diff = git("diff", "--unified=0", "HEAD...FETCH_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 # Do a normal update in final destination
copy_local(conf) copy_local(conf)
# Try to apply cached diff into final destination # Try to apply cached diff into final destination
with local.cwd(conf.dst_path): with local.cwd(conf.dst_path):
(git["apply", "--reject"] << diff)(retcode=None) (git["apply", "--reject"] << diff)(retcode=None)
# Run post-migration tasks
run_tasks(conf, renderer, get_migration_tasks(conf, "after"))
def get_source_paths( 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)) return bool(ask(f" Overwrite {dst_path}?", default=True))
def run_tasks(conf: ConfigData, render: Renderer) -> None: def run_tasks(conf: ConfigData, render: Renderer, tasks: StrSeq) -> None:
for i, task in enumerate(conf.tasks): for i, task in enumerate(tasks):
task = render.string(task) task = render.string(task)
# TODO: should we respect the `quiet` flag here as well? # 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) 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 import colorama
from jinja2 import FileSystemLoader from jinja2 import FileSystemLoader
from jinja2.sandbox import SandboxedEnvironment from jinja2.sandbox import SandboxedEnvironment
from packaging import version
from pydantic import StrictBool from pydantic import StrictBool
from ruamel.yaml import round_trip_dump from ruamel.yaml import round_trip_dump
from .config.objects import ConfigData, EnvOps 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") __all__ = ("Style", "printf")
@ -173,3 +182,19 @@ def get_name_filters(
return must_be_filtered(path) and not must_be_included(path) return must_be_filtered(path) and not must_be_included(path)
return must_filter, must_skip 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 import tempfile
from pathlib import Path 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 plumbum.cmd import git
from .types import OptStr from .types import OptStr, StrOrPath
__all__ = ("get_repo", "clone") __all__ = ("get_repo", "clone")
@ -52,6 +53,21 @@ def get_repo(url: str) -> OptStr:
return url 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: def clone(url: str, ref: str = "HEAD") -> str:
location = tempfile.mkdtemp() location = tempfile.mkdtemp()
shutil.rmtree(location) # Path must not exist shutil.rmtree(location) # Path must not exist

View File

@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
combine_as_imports = true combine_as_imports = true
force_grid_wrap = 0 force_grid_wrap = 0
include_trailing_comma = true 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 line_length = 88
multi_line_output = 3 multi_line_output = 3
use_parentheses = true use_parentheses = true

View File

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

Binary file not shown.

View File

@ -175,6 +175,7 @@ def test_config_data_good_data(dst):
"quiet": False, "quiet": False,
"skip": False, "skip": False,
"vcs_ref": None, "vcs_ref": None,
"migrations": (),
} }
conf = ConfigData(**expected) conf = ConfigData(**expected)
expected["data"]["_folder_name"] = dst.name 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): 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()) assert is_subdict(expected, conf.dict())

View File

@ -50,7 +50,7 @@ def test_copy(dst):
def test_copy_repo(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() assert (dst / "setup.py").exists()

View File

@ -8,8 +8,6 @@ from copier.cli import CopierApp
from .helpers import PROJECT_TEMPLATE from .helpers import PROJECT_TEMPLATE
COMMIT_1 = "49deace1b66f3a88a6305cc380d7596cc8170dc9"
COMMIT_2 = "c2ac5c45404cbd9b031acebcf398f19f56ce49dc"
REPO_BUNDLE_PATH = Path(f"{PROJECT_TEMPLATE}_updatediff_repo.bundle").absolute() REPO_BUNDLE_PATH = Path(f"{PROJECT_TEMPLATE}_updatediff_repo.bundle").absolute()
@ -18,15 +16,15 @@ def test_updatediff(dst: Path):
readme = target / "README.txt" readme = target / "README.txt"
answers = target / ".copier-answers.yml" answers = target / ".copier-answers.yml"
commit = git["commit", "--all", "--author", "Copier Test <test@copier>"] 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( 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 # Check it's copied OK
assert answers.read_text() == dedent( assert answers.read_text() == dedent(
f""" f"""
# Changes here will be overwritten by Copier # Changes here will be overwritten by Copier
_commit: {COMMIT_1} _commit: v0.0.1
_src_path: {REPO_BUNDLE_PATH} _src_path: {REPO_BUNDLE_PATH}
author_name: Guybrush author_name: Guybrush
project_name: to become a pirate project_name: to become a pirate
@ -46,28 +44,55 @@ def test_updatediff(dst: Path):
git("init") git("init")
git("add", ".") git("add", ".")
commit("-m", "hello world") commit("-m", "hello world")
# Emulate the user modifying the README by hand # Emulate the user modifying the README by hand
with open(readme, "w") as readme_fd: with open(readme, "w") as readme_fd:
readme_fd.write( readme_fd.write(
dedent( dedent(
""" """
Let me introduce myself. 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") 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) 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 # Check it's updated OK
assert answers.read_text() == dedent( assert answers.read_text() == dedent(
f""" f"""
# Changes here will be overwritten by Copier # Changes here will be overwritten by Copier
_commit: {COMMIT_2} _commit: v0.0.2-1-g81c335d
_src_path: {REPO_BUNDLE_PATH} _src_path: {REPO_BUNDLE_PATH}
author_name: Guybrush author_name: Guybrush
project_name: to become a pirate project_name: to become a pirate