Skip to content

Commit

Permalink
fix(exclude): support negative exclude matching child of excluded parent
Browse files Browse the repository at this point in the history
  • Loading branch information
sisp authored and yajo committed Oct 15, 2024
1 parent 9ddee99 commit 34bde5a
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 48 deletions.
100 changes: 53 additions & 47 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
escape_git_path,
normalize_git_path,
printf,
scantree,
set_git_alternates,
)
from .types import (
Expand Down Expand Up @@ -599,23 +600,41 @@ def match_skip(self) -> Callable[[Path], bool]:
)
)

def _render_file(self, src_abspath: Path) -> None:
def _render_template(self) -> None:
"""Render the template in the subproject root."""
for src in scantree(str(self.template_copy_root)):
src_abspath = Path(src.path)
src_relpath = Path(src_abspath).relative_to(self.template.local_abspath)
dst_relpath = self._render_path(
Path(src_abspath).relative_to(self.template_copy_root)
)
if dst_relpath is None or self.match_exclude(dst_relpath):
continue
if src.is_symlink() and self.template.preserve_symlinks:
self._render_symlink(src_relpath, dst_relpath)
elif src.is_dir(follow_symlinks=False):
self._render_folder(dst_relpath)
else:
self._render_file(src_relpath, dst_relpath)

def _render_file(self, src_relpath: Path, dst_relpath: Path) -> None:
"""Render one file.
Args:
src_abspath:
The absolute path to the file that will be rendered.
src_relpath:
File to be rendered. It must be a path relative to the template
root.
dst_relpath:
File to be created. It must be a path relative to the subproject
root.
"""
# TODO Get from main.render_file()
assert src_abspath.is_absolute()
src_relpath = src_abspath.relative_to(self.template.local_abspath).as_posix()
src_renderpath = src_abspath.relative_to(self.template_copy_root)
dst_relpath = self._render_path(src_renderpath)
if dst_relpath is None:
return
if src_abspath.name.endswith(self.template.templates_suffix):
assert not src_relpath.is_absolute()
assert not dst_relpath.is_absolute()
src_abspath = self.template.local_abspath / src_relpath
if src_relpath.name.endswith(self.template.templates_suffix):
try:
tpl = self.jinja_env.get_template(src_relpath)
tpl = self.jinja_env.get_template(src_relpath.as_posix())
except UnicodeDecodeError:
if self.template.templates_suffix:
# suffix is not empty, re-raise
Expand All @@ -626,7 +645,7 @@ def _render_file(self, src_abspath: Path) -> None:
new_content = tpl.render(**self._render_context()).encode()
else:
new_content = src_abspath.read_bytes()
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)
dst_abspath = self.subproject.local_abspath / dst_relpath
src_mode = src_abspath.stat().st_mode
if not self._render_allowed(dst_relpath, expected_contents=new_content):
return
Expand All @@ -639,21 +658,23 @@ def _render_file(self, src_abspath: Path) -> None:
dst_abspath.write_bytes(new_content)
dst_abspath.chmod(src_mode)

def _render_symlink(self, src_abspath: Path) -> None:
def _render_symlink(self, src_relpath: Path, dst_relpath: Path) -> None:
"""Render one symlink.
Args:
src_abspath:
Symlink to be rendered. It must be an absolute path within
the template.
src_relpath:
Symlink to be rendered. It must be a path relative to the
template root.
dst_relpath:
Symlink to be created. It must be a path relative to the
subproject root.
"""
assert src_abspath.is_absolute()
src_relpath = src_abspath.relative_to(self.template_copy_root)
dst_relpath = self._render_path(src_relpath)
if dst_relpath is None:
assert not src_relpath.is_absolute()
assert not dst_relpath.is_absolute()
if dst_relpath is None or self.match_exclude(dst_relpath):
return
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)

src_abspath = self.template.local_abspath / src_relpath
src_target = src_abspath.readlink()
if src_abspath.name.endswith(self.template.templates_suffix):
dst_target = Path(self._render_string(str(src_target)))
Expand All @@ -668,41 +689,30 @@ def _render_symlink(self, src_abspath: Path) -> None:
return

if not self.pretend:
dst_abspath = self.subproject.local_abspath / dst_relpath
# symlink_to doesn't overwrite existing files, so delete it first
if dst_abspath.is_symlink() or dst_abspath.exists():
dst_abspath.unlink()
dst_abspath.parent.mkdir(parents=True, exist_ok=True)
dst_abspath.symlink_to(dst_target)
if sys.platform == "darwin":
# Only macOS supports permissions on symlinks.
# Other platforms just copy the permission of the target
src_mode = src_abspath.lstat().st_mode
dst_abspath.lchmod(src_mode)

def _render_folder(self, src_abspath: Path) -> None:
"""Recursively render a folder.
def _render_folder(self, dst_relpath: Path) -> None:
"""Create one folder (without content).
Args:
src_abspath:
Folder to be rendered. It must be an absolute path within
the template.
dst_relpath:
Folder to be created. It must be a path relative to the
subproject root.
"""
assert src_abspath.is_absolute()
src_relpath = src_abspath.relative_to(self.template_copy_root)
dst_relpath = self._render_path(src_relpath)
if dst_relpath is None:
return
if not self._render_allowed(dst_relpath, is_dir=True):
return
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)
if not self.pretend:
assert not dst_relpath.is_absolute()
if not self.pretend and self._render_allowed(dst_relpath, is_dir=True):
dst_abspath = self.subproject.local_abspath / dst_relpath
dst_abspath.mkdir(parents=True, exist_ok=True)
for file in src_abspath.iterdir():
if file.is_symlink() and self.template.preserve_symlinks:
self._render_symlink(file)
elif file.is_dir():
self._render_folder(file)
else:
self._render_file(file)

def _render_path(self, relpath: Path) -> Path | None:
"""Render one relative path.
Expand Down Expand Up @@ -732,9 +742,6 @@ def _render_path(self, relpath: Path) -> Path | None:
part = Path(part).name
rendered_parts.append(part)
result = Path(*rendered_parts)
# Skip excluded paths.
if result != Path(".") and self.match_exclude(result):
return None
if not is_template:
templated_sibling = (
self.template.local_abspath
Expand Down Expand Up @@ -824,15 +831,14 @@ def run_copy(self) -> None:
self._print_message(self.template.message_before_copy)
self._ask()
was_existing = self.subproject.local_abspath.exists()
src_abspath = self.template_copy_root
try:
if not self.quiet:
# TODO Unify printing tools
print(
f"\nCopying from template version {self.template.version}",
file=sys.stderr,
)
self._render_folder(src_abspath)
self._render_template()
if not self.quiet:
# TODO Unify printing tools
print("") # padding space
Expand Down
10 changes: 9 additions & 1 deletion copier/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from importlib.metadata import version
from pathlib import Path
from types import TracebackType
from typing import Any, Callable, Literal, TextIO, cast
from typing import Any, Callable, Iterator, Literal, TextIO, cast

import colorama
from packaging.version import Version
Expand Down Expand Up @@ -256,3 +256,11 @@ def set_git_alternates(*repos: Path, path: Path = Path(".")) -> None:
alternates_file = get_git_objects_dir(path) / "info" / "alternates"
alternates_file.parent.mkdir(parents=True, exist_ok=True)
alternates_file.write_bytes(b"\n".join(map(bytes, map(get_git_objects_dir, repos))))


def scantree(path: str) -> Iterator[os.DirEntry[str]]:
"""A recursive extension of `os.scandir`."""
for entry in os.scandir(path):
yield entry
if entry.is_dir(follow_symlinks=False):
yield from scantree(entry.path)
32 changes: 32 additions & 0 deletions tests/test_exclude.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,35 @@ def test_config_exclude_with_templated_path(
run_copy(str(src), dst, defaults=True, quiet=True)
assert (dst / "keep-me.txt").exists()
assert not (dst / "exclude-me.txt").exists()


def test_exclude_wildcard_negate_nested_file(
tmp_path_factory: pytest.TempPathFactory,
) -> None:
"""Test wildcard exclude with negated nested file and symlink excludes."""
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yml"): (
"""\
_preserve_symlinks: true
_exclude:
- "*"
- "!/foo/keep-me.txt"
- "!/foo/bar/keep-me-symlink.txt"
"""
),
(src / "exclude-me.txt"): "",
(src / "foo" / "keep-me.txt"): "",
(src / "foo" / "bar" / "keep-me-symlink.txt"): Path("..", "keep-me.txt"),
}
)
run_copy(str(src), dst)
assert (dst / "foo" / "keep-me.txt").exists()
assert (dst / "foo" / "keep-me.txt").is_file()
assert (dst / "foo" / "bar" / "keep-me-symlink.txt").exists()
assert (dst / "foo" / "bar" / "keep-me-symlink.txt").is_symlink()
assert (dst / "foo" / "bar" / "keep-me-symlink.txt").readlink() == Path(
"..", "keep-me.txt"
)
assert not (dst / "exclude-me.txt").exists()

0 comments on commit 34bde5a

Please sign in to comment.