"""A pytest plugin to build copier project from a template."""
from dataclasses import dataclass, field
from pathlib import Path
from shutil import copy2, copytree, rmtree
from typing import Callable, Generator, List, Optional, Union
import plumbum
import plumbum.machines
import pytest
import yaml
from _pytest.tmpdir import TempPathFactory
from copier import run_copy, run_update
@dataclass
[docs]
class Result:
"""Holds the captured result of the copier project generation."""
[docs]
exception: Union[Exception, SystemExit, None] = None
"The exception raised during the copier project generation."
[docs]
exit_code: Union[str, int, None] = 0
"The exit code of the copier project generation."
[docs]
project_dir: Optional[Path] = None
"The path to the generated project."
[docs]
answers: dict = field(default_factory=dict)
"The answers used to generate the project."
def __repr__(self) -> str:
"""Return a string representation of the result."""
return f"<Result {self.exception or self.project_dir}>"
_GIT_AUTHOR = "Pytest Copie"
_GIT_EMAIL = "pytest@example.com"
_git: plumbum.machines.LocalCommand = plumbum.cmd.git.with_env(
GIT_AUTHOR_NAME=_GIT_AUTHOR,
GIT_AUTHOR_EMAIL=_GIT_EMAIL,
GIT_COMMITTER_NAME=_GIT_AUTHOR,
GIT_COMMITTER_EMAIL=_GIT_EMAIL,
)
"""A handle to allow execution of git commands during tests."""
@dataclass
[docs]
class Copie:
"""Class to provide convenient access to the copier API."""
[docs]
default_template_dir: Path
"The path to the default template to use to create the project."
"The directory where the project will be created."
"The path to the copier config file."
[docs]
parent_result: Optional[Result] = None
"Template generated by the parent template to be used in combination with default_template."
"A counter to keep track of the number of projects created."
[docs]
def git(self) -> plumbum.machines.LocalCommand:
"""A handle to allow execution of git commands during tests."""
return _git
[docs]
def copy(
self, extra_answers: dict = {}, template_dir: Optional[Path] = None, vcs_ref: str = "HEAD"
) -> Result:
"""Create a copier Project from the template and return the associated :py:class:`Result <pytest_copie.plugin.Result>` object.
Args:
extra_answers: extra answers to pass to the Copie object and overwrite the default ones
template_dir: the path to the template to use to create the project instead of the default ".".
vcs_ref: the commit hash, tag or branch to use from the template repo, for the copy
Returns:
the result of the copier project generation
"""
# Check for valid parent_result if provided
if self.parent_result is not None:
if self.parent_result.project_dir is None:
raise ValueError("parent_result.project_dir must be set.")
if not isinstance(self.parent_result.project_dir, Path):
raise ValueError("parent_result.project_dir must be a Path object.")
if not self.parent_result.project_dir.exists():
raise ValueError("parent_result.project_dir must exist.")
if not self.parent_result.exit_code == 0:
raise ValueError(
"parent_result must have a successful exit code (0) to be used as a parent."
)
# set the template dir and the associated copier.yaml file
template_dir = template_dir or self.default_template_dir
files = template_dir.glob("copier.*")
try:
copier_yaml = next(f for f in files if f.suffix in [".yaml", ".yml"])
except StopIteration:
raise FileNotFoundError("No copier.yaml configuration file found.")
# create a new output_dir in the test dir based on the counter value
(output_dir := self.test_dir / f"copie{self.counter:03d}").mkdir()
self.counter += 1
# Copy contents from parent_result.project_dir into output_dir
if self.parent_result:
if self.parent_result.project_dir is None:
raise ValueError("parent_result.project_dir must be set.")
else:
for item in self.parent_result.project_dir.iterdir():
dest = output_dir / item.name
copy_method = copytree if item.is_dir() else copy2
copy_method(item, dest)
try:
# make sure the copiercopier project is using subdirectories
_add_yaml_include_constructor(template_dir)
all_params = yaml.safe_load_all(copier_yaml.read_text())
if not any("_subdirectory" in params for params in all_params):
raise ValueError(
"The plugin can only work for templates using subdirectories, "
'"_subdirectory" key is missing from copier.yaml'
)
worker = run_copy(
src_path=str(template_dir),
dst_path=str(output_dir),
unsafe=True,
defaults=True,
user_defaults=extra_answers,
vcs_ref=vcs_ref or "HEAD",
)
# refresh project_dir with the generated one
# the project path will be the first child of the ouptut_dir
project_dir = Path(worker.dst_path)
# refresh answers with the generated ones and remove private stuff
answers = worker._answers_to_remember()
answers = {q: a for q, a in answers.items() if not q.startswith("_")}
return Result(project_dir=project_dir, answers=answers)
except SystemExit as e:
return Result(exception=e, exit_code=e.code)
except Exception as e:
return Result(exception=e, exit_code=-1)
[docs]
def update(
self, result: Result, extra_answers: Optional[dict] = None, vcs_ref: str = "HEAD"
) -> Result:
"""Update a copier Project from the template and return the associated :py:class:`Result <pytest_copie.plugin.Result>` object, returns a new :py:class:`Result <pytest_copie.plugin.Result>`.
Args:
result: results obtained when the project was first created
extra_answers: extra answers to pass to the Copie object and overwrite the default ones
vcs_ref: the commit/tag to use for the update
Returns:
the result of the copier project update
"""
assert (
result.project_dir is not None
) and result.project_dir.exists(), "To update, `result.project_dir` must exist"
try:
worker = run_update(
dst_path=str(result.project_dir),
unsafe=True,
defaults=True,
overwrite=True,
user_defaults=extra_answers if extra_answers is not None else {},
vcs_ref=vcs_ref,
)
# refresh answers with the generated ones and remove private stuff
answers = worker._answers_to_remember()
answers = {q: a for q, a in answers.items() if not q.startswith("_")}
return Result(project_dir=result.project_dir, answers=answers)
except SystemExit as e:
return Result(exception=e, exit_code=e.code)
except Exception as e:
return Result(exception=e, exit_code=-1)
@pytest.fixture(scope="session")
def _copier_config_file(tmp_path_factory) -> Path:
"""Return a temporary copier config file."""
# create a user from the tmp_path_factory fixture
user_dir = tmp_path_factory.mktemp("user_dir")
# create the different folders and files
(copier_dir := user_dir / "copier").mkdir()
(replay_dir := user_dir / "copier_replay").mkdir()
# set up the configuration parameters in a config file
config = {"copier_dir": str(copier_dir), "replay_dir": str(replay_dir)}
(config_file := user_dir / "config").write_text(yaml.dump(config))
return config_file
@pytest.fixture
[docs]
def copie(
request: Union[pytest.FixtureRequest, None],
tmp_path: Path,
_copier_config_file: Path,
parent_tpl: Optional[Path] = None,
) -> Generator:
"""Yield an instance of the :py:class:`Copie <pytest_copie.plugin.Copie>` helper class.
The class can then be used to generate a project from a template.
Args:
request: the pytest request object (None when used outside of pytest)
tmp_path: the temporary directory
_copier_config_file: the temporary copier config file
parent_tpl: the path to the parent template directory,
must be provided when used outside of pytest.
Returns:
the object instance, ready to copy !
"""
if request is None and parent_tpl is None:
raise ValueError(
"When not used in pytest, the 'parent_template_dir' argument must be provided."
)
# If in pytest, use the template directory from the pytest command parameter
if parent_tpl is None:
if request is None:
raise ValueError(
"The 'parent_template_dir' argument must be provided when not in pytest."
)
if getattr(request.config.option, "template", None) is None:
raise ValueError("The 'template' pytest option must be set to use the 'copie' fixture.")
parent_tpl = Path(request.config.option.template)
# list to keep track of each applied template
created_dirs: List[Path] = []
# set up a test directory in the tmp folder for the 1st template to apply
parent_dir = tmp_path / "copie"
parent_dir.mkdir()
created_dirs.append(parent_dir)
# Create the primary Copie instance
# which will be used to apply the first template
primary = Copie(
default_template_dir=parent_tpl,
test_dir=parent_dir,
config_file=_copier_config_file,
)
def _spawn_child(
*,
parent_result: Optional[Result] = None,
child_tpl: Path,
) -> "Copie":
"""
Create a child Copie instance to apply a new template.
Args:
parent_result: the result of the parent Copie instance, if any
child_tpl: the path to the child template directory
Returns:
A new instance of the Copie class, ready to copy a new template.
"""
child_dir = tmp_path / f"copie_{len(created_dirs):03d}"
child_dir.mkdir()
created_dirs.append(child_dir)
return Copie(
default_template_dir=child_tpl,
test_dir=child_dir,
config_file=_copier_config_file,
parent_result=parent_result,
)
class CopieHandle:
"""Acts like `Copie` *and* like a factory for more `Copie`s."""
def __init__(self, primary: Copie, factory: Callable[..., Copie]):
self._primary = primary
self._factory = factory
# delegate every unknown attribute to the primary instance
def __getattr__(self, item):
return getattr(self._primary, item)
# being *callable* makes the handle a factory
def __call__(self, *args, **kwargs):
return self._factory(*args, **kwargs)
# create the handle to the primary Copie instance
handle = CopieHandle(primary, _spawn_child)
yield handle
# Common cleanup after tests
if request is not None and not request.config.option.keep_copied_projects:
for d in reversed(created_dirs):
rmtree(d, ignore_errors=True)
@pytest.fixture(scope="session")
[docs]
def copie_session(
request,
tmp_path_factory: TempPathFactory,
_copier_config_file: Path,
) -> Generator:
"""Yield an instance of the :py:class:`Copie <pytest_copie.plugin.Copie>` helper class.
The class can then be used to generate a project from a template.
Args:
request: the pytest request object
tmp_path_factory: the temporary directory
_copier_config_file: the temporary copier config file
Returns:
the object instance, ready to copy !
"""
# extract the template directory from the pytest command parameter
template_dir = Path(request.config.option.template)
# set up a test directory in the tmp folder
test_dir = tmp_path_factory.mktemp("copie")
yield Copie(template_dir, test_dir, _copier_config_file)
# don't delete the files at the end of the test if requested
if not request.config.option.keep_copied_projects:
rmtree(test_dir, ignore_errors=True)
[docs]
def pytest_addoption(parser):
"""Add option to the pytest command."""
group = parser.getgroup("copie")
group.addoption(
"--template",
action="store",
default=".",
dest="template",
help="specify the template to be rendered",
type=str,
)
group.addoption(
"--keep-copied-projects",
action="store_true",
default=False,
dest="keep_copied_projects",
help="Keep projects directories generated with 'copie.copie()'.",
)
def _add_yaml_include_constructor(
template_dir: Path,
):
"""Adds an include constructor to yaml.SafeLoader."""
def include_constructor(
loader: yaml.SafeLoader,
node: yaml.Node,
):
fullpath = template_dir / node.value
if not fullpath.is_file():
raise FileNotFoundError(f"The filename '{fullpath}' does not exist.")
with fullpath.open("rb") as f:
return yaml.safe_load(f)
yaml.add_constructor("!include", include_constructor, yaml.SafeLoader)