Skip to content

Commit 34bde5a

Browse files
sispyajo
authored andcommitted
fix(exclude): support negative exclude matching child of excluded parent
1 parent 9ddee99 commit 34bde5a

File tree

3 files changed

+94
-48
lines changed

3 files changed

+94
-48
lines changed

copier/main.py

+53-47
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
escape_git_path,
5454
normalize_git_path,
5555
printf,
56+
scantree,
5657
set_git_alternates,
5758
)
5859
from .types import (
@@ -599,23 +600,41 @@ def match_skip(self) -> Callable[[Path], bool]:
599600
)
600601
)
601602

602-
def _render_file(self, src_abspath: Path) -> None:
603+
def _render_template(self) -> None:
604+
"""Render the template in the subproject root."""
605+
for src in scantree(str(self.template_copy_root)):
606+
src_abspath = Path(src.path)
607+
src_relpath = Path(src_abspath).relative_to(self.template.local_abspath)
608+
dst_relpath = self._render_path(
609+
Path(src_abspath).relative_to(self.template_copy_root)
610+
)
611+
if dst_relpath is None or self.match_exclude(dst_relpath):
612+
continue
613+
if src.is_symlink() and self.template.preserve_symlinks:
614+
self._render_symlink(src_relpath, dst_relpath)
615+
elif src.is_dir(follow_symlinks=False):
616+
self._render_folder(dst_relpath)
617+
else:
618+
self._render_file(src_relpath, dst_relpath)
619+
620+
def _render_file(self, src_relpath: Path, dst_relpath: Path) -> None:
603621
"""Render one file.
604622
605623
Args:
606-
src_abspath:
607-
The absolute path to the file that will be rendered.
624+
src_relpath:
625+
File to be rendered. It must be a path relative to the template
626+
root.
627+
dst_relpath:
628+
File to be created. It must be a path relative to the subproject
629+
root.
608630
"""
609631
# TODO Get from main.render_file()
610-
assert src_abspath.is_absolute()
611-
src_relpath = src_abspath.relative_to(self.template.local_abspath).as_posix()
612-
src_renderpath = src_abspath.relative_to(self.template_copy_root)
613-
dst_relpath = self._render_path(src_renderpath)
614-
if dst_relpath is None:
615-
return
616-
if src_abspath.name.endswith(self.template.templates_suffix):
632+
assert not src_relpath.is_absolute()
633+
assert not dst_relpath.is_absolute()
634+
src_abspath = self.template.local_abspath / src_relpath
635+
if src_relpath.name.endswith(self.template.templates_suffix):
617636
try:
618-
tpl = self.jinja_env.get_template(src_relpath)
637+
tpl = self.jinja_env.get_template(src_relpath.as_posix())
619638
except UnicodeDecodeError:
620639
if self.template.templates_suffix:
621640
# suffix is not empty, re-raise
@@ -626,7 +645,7 @@ def _render_file(self, src_abspath: Path) -> None:
626645
new_content = tpl.render(**self._render_context()).encode()
627646
else:
628647
new_content = src_abspath.read_bytes()
629-
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)
648+
dst_abspath = self.subproject.local_abspath / dst_relpath
630649
src_mode = src_abspath.stat().st_mode
631650
if not self._render_allowed(dst_relpath, expected_contents=new_content):
632651
return
@@ -639,21 +658,23 @@ def _render_file(self, src_abspath: Path) -> None:
639658
dst_abspath.write_bytes(new_content)
640659
dst_abspath.chmod(src_mode)
641660

642-
def _render_symlink(self, src_abspath: Path) -> None:
661+
def _render_symlink(self, src_relpath: Path, dst_relpath: Path) -> None:
643662
"""Render one symlink.
644663
645664
Args:
646-
src_abspath:
647-
Symlink to be rendered. It must be an absolute path within
648-
the template.
665+
src_relpath:
666+
Symlink to be rendered. It must be a path relative to the
667+
template root.
668+
dst_relpath:
669+
Symlink to be created. It must be a path relative to the
670+
subproject root.
649671
"""
650-
assert src_abspath.is_absolute()
651-
src_relpath = src_abspath.relative_to(self.template_copy_root)
652-
dst_relpath = self._render_path(src_relpath)
653-
if dst_relpath is None:
672+
assert not src_relpath.is_absolute()
673+
assert not dst_relpath.is_absolute()
674+
if dst_relpath is None or self.match_exclude(dst_relpath):
654675
return
655-
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)
656676

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

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

681-
def _render_folder(self, src_abspath: Path) -> None:
682-
"""Recursively render a folder.
704+
def _render_folder(self, dst_relpath: Path) -> None:
705+
"""Create one folder (without content).
683706
684707
Args:
685-
src_abspath:
686-
Folder to be rendered. It must be an absolute path within
687-
the template.
708+
dst_relpath:
709+
Folder to be created. It must be a path relative to the
710+
subproject root.
688711
"""
689-
assert src_abspath.is_absolute()
690-
src_relpath = src_abspath.relative_to(self.template_copy_root)
691-
dst_relpath = self._render_path(src_relpath)
692-
if dst_relpath is None:
693-
return
694-
if not self._render_allowed(dst_relpath, is_dir=True):
695-
return
696-
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)
697-
if not self.pretend:
712+
assert not dst_relpath.is_absolute()
713+
if not self.pretend and self._render_allowed(dst_relpath, is_dir=True):
714+
dst_abspath = self.subproject.local_abspath / dst_relpath
698715
dst_abspath.mkdir(parents=True, exist_ok=True)
699-
for file in src_abspath.iterdir():
700-
if file.is_symlink() and self.template.preserve_symlinks:
701-
self._render_symlink(file)
702-
elif file.is_dir():
703-
self._render_folder(file)
704-
else:
705-
self._render_file(file)
706716

707717
def _render_path(self, relpath: Path) -> Path | None:
708718
"""Render one relative path.
@@ -732,9 +742,6 @@ def _render_path(self, relpath: Path) -> Path | None:
732742
part = Path(part).name
733743
rendered_parts.append(part)
734744
result = Path(*rendered_parts)
735-
# Skip excluded paths.
736-
if result != Path(".") and self.match_exclude(result):
737-
return None
738745
if not is_template:
739746
templated_sibling = (
740747
self.template.local_abspath
@@ -824,15 +831,14 @@ def run_copy(self) -> None:
824831
self._print_message(self.template.message_before_copy)
825832
self._ask()
826833
was_existing = self.subproject.local_abspath.exists()
827-
src_abspath = self.template_copy_root
828834
try:
829835
if not self.quiet:
830836
# TODO Unify printing tools
831837
print(
832838
f"\nCopying from template version {self.template.version}",
833839
file=sys.stderr,
834840
)
835-
self._render_folder(src_abspath)
841+
self._render_template()
836842
if not self.quiet:
837843
# TODO Unify printing tools
838844
print("") # padding space

copier/tools.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from importlib.metadata import version
1414
from pathlib import Path
1515
from types import TracebackType
16-
from typing import Any, Callable, Literal, TextIO, cast
16+
from typing import Any, Callable, Iterator, Literal, TextIO, cast
1717

1818
import colorama
1919
from packaging.version import Version
@@ -256,3 +256,11 @@ def set_git_alternates(*repos: Path, path: Path = Path(".")) -> None:
256256
alternates_file = get_git_objects_dir(path) / "info" / "alternates"
257257
alternates_file.parent.mkdir(parents=True, exist_ok=True)
258258
alternates_file.write_bytes(b"\n".join(map(bytes, map(get_git_objects_dir, repos))))
259+
260+
261+
def scantree(path: str) -> Iterator[os.DirEntry[str]]:
262+
"""A recursive extension of `os.scandir`."""
263+
for entry in os.scandir(path):
264+
yield entry
265+
if entry.is_dir(follow_symlinks=False):
266+
yield from scantree(entry.path)

tests/test_exclude.py

+32
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,35 @@ def test_config_exclude_with_templated_path(
230230
run_copy(str(src), dst, defaults=True, quiet=True)
231231
assert (dst / "keep-me.txt").exists()
232232
assert not (dst / "exclude-me.txt").exists()
233+
234+
235+
def test_exclude_wildcard_negate_nested_file(
236+
tmp_path_factory: pytest.TempPathFactory,
237+
) -> None:
238+
"""Test wildcard exclude with negated nested file and symlink excludes."""
239+
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
240+
build_file_tree(
241+
{
242+
(src / "copier.yml"): (
243+
"""\
244+
_preserve_symlinks: true
245+
_exclude:
246+
- "*"
247+
- "!/foo/keep-me.txt"
248+
- "!/foo/bar/keep-me-symlink.txt"
249+
"""
250+
),
251+
(src / "exclude-me.txt"): "",
252+
(src / "foo" / "keep-me.txt"): "",
253+
(src / "foo" / "bar" / "keep-me-symlink.txt"): Path("..", "keep-me.txt"),
254+
}
255+
)
256+
run_copy(str(src), dst)
257+
assert (dst / "foo" / "keep-me.txt").exists()
258+
assert (dst / "foo" / "keep-me.txt").is_file()
259+
assert (dst / "foo" / "bar" / "keep-me-symlink.txt").exists()
260+
assert (dst / "foo" / "bar" / "keep-me-symlink.txt").is_symlink()
261+
assert (dst / "foo" / "bar" / "keep-me-symlink.txt").readlink() == Path(
262+
"..", "keep-me.txt"
263+
)
264+
assert not (dst / "exclude-me.txt").exists()

0 commit comments

Comments
 (0)