Repository: copier-org/copier (python)

Issue:
`preserve_symlinks=False` not working for directory symlinks in 9.4
### Describe the problem

When using a template containing symlinked directories (like [here](https://github.com/DiamondLightSource/python-copier-template/blob/2.3.0/template/.vscode)) then in 9.3.1 it would follow the link, and render the contents of the directory. In 9.4 it errors with:
```python
Traceback (most recent call last):
  File "/venv/bin/copier", line 8, in <module>
    sys.exit(copier_app_run())
             ^^^^^^^^^^^^^^^^
  File "/venv/lib/python3.11/site-packages/plumbum/cli/application.py", line 640, in run
    inst, retcode = subapp.run(argv, exit=False)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/venv/lib/python3.11/site-packages/plumbum/cli/application.py", line 635, in run
    retcode = inst.main(*tailargs)
              ^^^^^^^^^^^^^^^^^^^^
  File "/venv/lib/python3.11/site-packages/copier/cli.py", line 281, in main
    return _handle_exceptions(inner)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/venv/lib/python3.11/site-packages/copier/cli.py", line 70, in _handle_exceptions
    method()
  File "/venv/lib/python3.11/site-packages/copier/cli.py", line 272, in inner
    with self._worker(
  File "/venv/lib/python3.11/site-packages/copier/main.py", line 237, in __exit__
    raise value
  File "/venv/lib/python3.11/site-packages/copier/cli.py", line 279, in inner
    worker.run_copy()
  File "/venv/lib/python3.11/site-packages/copier/main.py", line 841, in run_copy
    self._render_template()
  File "/venv/lib/python3.11/site-packages/copier/main.py", line 618, in _render_template
    self._render_file(src_relpath, dst_relpath)
  File "/venv/lib/python3.11/site-packages/copier/main.py", line 647, in _render_file
    new_content = src_abspath.read_bytes()
                  ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/pathlib.py", line 1050, in read_bytes
    with self.open(mode='rb') as f:
         ^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/pathlib.py", line 1044, in open
    return io.open(self, mode, buffering, encoding, errors, newline)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
IsADirectoryError: [Errno 21] Is a directory: '/tmp/copier.vcs.clone.akuro384/template/.vscode'
```

### Template

https://github.com/DiamondLightSource/python-copier-template/blob/2.3.0

### To Reproduce

1. `wget https://raw.githubusercontent.com/DiamondLightSource/python-copier-template/refs/tags/2.3.0/example-answers.yml`
2. `copier copy --vcs-ref=2.3.0 --trust --data-file example-answers.yml gh:DiamondLightSource/python-copier-template /tmp/foo`
3. See the traceback

### Logs

_No response_

### Expected behavior

Expected to render the contents like with 9.3.1

### Screenshots/screencasts/logs

_No response_

### Operating system

Linux

### Operating system distribution and version

python:3.11 container

### Copier version

9.4.0

### Python version

CPython 3.11

### Installation method

pip+pypi

### Additional context

The relevant code in 9.3.1: https://github.com/copier-org/copier/blob/53f1901822e795a033659bd1ac28949fa49e2003/copier/main.py#L691-L697

Which works as:
```Python 3.11.10 (main, Sep 27 2024, 06:09:18) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pathlib import Path
>>> p = Path("./template/.vscode")
>>> p.is_dir()
True
>>> p.is_symlink()
True
>>> 
```

While the code in 9.4: https://github.com/copier-org/copier/blob/59f13c079864b0cdc7aabc1bca3babb2f95f9ba0/copier/main.py#L613-L618

Which doesn't work as:
```
>>> xs = list(os.scandir("./template"))
>>> xs
[<DirEntry '.devcontainer'>, <DirEntry '.gitignore'>, <DirEntry '.pre-commit-config.yaml'>, <DirEntry '.vscode'>, <DirEntry 'Dockerfile.jinja'>, <DirEntry 'LICENSE'>, <DirEntry 'README.md.jinja'>, <DirEntry 'pyproject.toml.jinja'>, <DirEntry 'src'>, <DirEntry 'tests'>, <DirEntry '{% if git_platform=="github.com" %}.github{% endif %}'>, <DirEntry '{% if sphinx %}docs{% endif %}'>, <DirEntry '{{ _copier_conf.answers_file }}.jinja'>, <DirEntry '{% if github_org == "DiamondLightSource" %}catalog-info.yaml{% endif %}.jinja'>]
>>> xs[3]
<DirEntry '.vscode'>
>>> xs[3].is_dir()
True
>>> xs[3].is_dir(follow_symlinks=False)
False
```

Changing https://github.com/copier-org/copier/blob/59f13c079864b0cdc7aabc1bca3babb2f95f9ba0/copier/main.py#L615 to be 

```python
elif src.is_dir(follow_symlinks=not self.template.preserve_symlinks):
```
and the same change in https://github.com/copier-org/copier/blob/59f13c079864b0cdc7aabc1bca3babb2f95f9ba0/copier/tools.py#L265

fixes it for me


Pull request:
Support preserve_symlinks=False for directories
Fixes #1819

Patch:
diff --git a/copier/main.py b/copier/main.py
index 60156e7..69171bd 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -602,7 +602,8 @@ class Worker:
 
     def _render_template(self) -> None:
         """Render the template in the subproject root."""
-        for src in scantree(str(self.template_copy_root)):
+        follow_symlinks = not self.template.preserve_symlinks
+        for src in scantree(str(self.template_copy_root), follow_symlinks):
             src_abspath = Path(src.path)
             src_relpath = Path(src_abspath).relative_to(self.template.local_abspath)
             dst_relpath = self._render_path(
@@ -612,7 +613,7 @@ class Worker:
                 continue
             if src.is_symlink() and self.template.preserve_symlinks:
                 self._render_symlink(src_relpath, dst_relpath)
-            elif src.is_dir(follow_symlinks=False):
+            elif src.is_dir(follow_symlinks=follow_symlinks):
                 self._render_folder(dst_relpath)
             else:
                 self._render_file(src_relpath, dst_relpath)
diff --git a/copier/tools.py b/copier/tools.py
index 01a953a..983e712 100644
--- a/copier/tools.py
+++ b/copier/tools.py
@@ -258,9 +258,9 @@ def set_git_alternates(*repos: Path, path: Path = Path(".")) -> None:
     alternates_file.write_bytes(b"\n".join(map(bytes, map(get_git_objects_dir, repos))))
 
 
-def scantree(path: str) -> Iterator[os.DirEntry[str]]:
+def scantree(path: str, follow_symlinks: bool) -> 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)
+        if entry.is_dir(follow_symlinks=follow_symlinks):
+            yield from scantree(entry.path, follow_symlinks)


Test patch:
diff --git a/tests/test_symlinks.py b/tests/test_symlinks.py
index 8ea8d36..a9e90e5 100644
--- a/tests/test_symlinks.py
+++ b/tests/test_symlinks.py
@@ -464,3 +464,18 @@ def test_recursive_symlink(tmp_path_factory: pytest.TempPathFactory) -> None:
     run_copy(str(src), dst, defaults=True, overwrite=True)
     assert (dst / "one" / "two" / "three" / "root").is_symlink()
     assert (dst / "one" / "two" / "three" / "root").readlink() == Path("../../../")
+
+
+def test_symlinked_dir_expanded(tmp_path_factory: pytest.TempPathFactory) -> None:
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            src / "a_dir" / "a_file.txt": "some content",
+            src / "a_symlinked_dir": Path("a_dir"),
+            src / "a_nested" / "symlink": Path("../a_dir"),
+        }
+    )
+    run_copy(str(src), dst, defaults=True, overwrite=True)
+    assert (dst / "a_dir" / "a_file.txt").read_text() == "some content"
+    assert (dst / "a_symlinked_dir" / "a_file.txt").read_text() == "some content"
+    assert (dst / "a_nested" / "symlink" / "a_file.txt").read_text() == "some content"