Repository: copier-org/copier
Pull request #1984 (2025-02-23 15:08:46)

style: use pathlib

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/copier-org/copier/pull/1984?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) Report
Attention: Patch coverage is `97.56098%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 94.27%. Comparing base [(`c53ced0`)](https://app.codecov.io/gh/copier-org/copier/commit/c53ced019ca5d049b2b9c17f0bdaca06f1b604ec?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) to head [(`af3fb15`)](https://app.codecov.io/gh/copier-org/copier/commit/af3fb15aa904a1b47a087966a641fd7fde99cff6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org).
> Report is 1 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/copier-org/copier/pull/1984?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | Patch % | Lines |
|---|---|---|
| [copier/tools.py](https://app.codecov.io/gh/copier-org/copier/pull/1984?src=pr&el=tree&filepath=copier%2Ftools.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org#diff-Y29waWVyL3Rvb2xzLnB5) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/copier-org/copier/pull/1984?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1984      +/-   ##
==========================================
- Coverage   98.06%   94.27%   -3.79%     
==========================================
  Files          53       53              
  Lines        5575     5573       -2     
==========================================
- Hits         5467     5254     -213     
- Misses        108      319     +211     
```

| [Flag](https://app.codecov.io/gh/copier-org/copier/pull/1984/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/copier-org/copier/pull/1984/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | `94.27% <97.56%> (-3.79%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/copier-org/copier/pull/1984?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org).


Patch:
diff --git a/copier/cli.py b/copier/cli.py
index 9cb269e..47532c1 100644
--- a/copier/cli.py
+++ b/copier/cli.py
@@ -189,7 +189,7 @@ class _Subcommand(cli.Application):  # type: ignore[misc]
         Arguments:
             path: The path to the YAML file to load.
         """
-        with open(path) as f:
+        with Path(path).open() as f:
             file_updates: AnyByStrDict = yaml.safe_load(f)
 
         updates_without_cli_overrides = {
diff --git a/copier/main.py b/copier/main.py
index cbacde8..84dc32b 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -204,7 +204,7 @@ class Worker:
     """
 
     src_path: str | None = None
-    dst_path: Path = Path(".")
+    dst_path: Path = Path()
     answers_file: RelativePath | None = None
     vcs_ref: str | None = None
     data: AnyByStrDict = field(default_factory=dict)
@@ -859,7 +859,7 @@ class Worker:
         """
         is_template = relpath.name.endswith(self.template.templates_suffix)
         templated_sibling = (
-            self.template.local_abspath / f"{relpath}{self.template.templates_suffix}"
+            self.template_copy_root / f"{relpath}{self.template.templates_suffix}"
         )
         # With an empty suffix, the templated sibling always exists.
         if templated_sibling.exists() and self.template.templates_suffix:
@@ -1235,7 +1235,7 @@ class Worker:
                         # The 3-way merge might have resolved conflicts automatically,
                         # so we need to check if the file contains conflict markers
                         # before storing the file name for marking it as unmerged after the loop.
-                        with open(fname) as conflicts_candidate:
+                        with Path(fname).open() as conflicts_candidate:
                             if any(
                                 line.rstrip()
                                 in {"<<<<<<< before updating", ">>>>>>> after updating"}
diff --git a/copier/settings.py b/copier/settings.py
index d67813b..6a9069b 100644
--- a/copier/settings.py
+++ b/copier/settings.py
@@ -57,5 +57,5 @@ class Settings(BaseModel):
     def normalize(self, url: str) -> str:
         """Normalize an URL using user settings."""
         if url.startswith("~"):  # Only expand on str to avoid messing with URLs
-            url = expanduser(url)
+            url = expanduser(url)  # noqa: PTH111
         return url
diff --git a/copier/template.py b/copier/template.py
index e47b1a3..fca1f92 100644
--- a/copier/template.py
+++ b/copier/template.py
@@ -173,7 +173,7 @@ class Task:
     cmd: str | Sequence[str]
     extra_vars: dict[str, Any] = field(default_factory=dict)
     condition: str | bool = True
-    working_directory: Path = Path(".")
+    working_directory: Path = Path()
 
 
 @dataclass
@@ -325,7 +325,7 @@ class Template:
         return tuple(
             self.config_data.get(
                 "exclude",
-                DEFAULT_EXCLUDE if Path(self.subdirectory) == Path(".") else [],
+                DEFAULT_EXCLUDE if Path(self.subdirectory) == Path() else [],
             )
         )
 
diff --git a/copier/tools.py b/copier/tools.py
index ea3d0e1..28250d3 100644
--- a/copier/tools.py
+++ b/copier/tools.py
@@ -169,7 +169,7 @@ def handle_remove_readonly(
     excvalue = cast(OSError, exc if isinstance(exc, BaseException) else exc[1])
 
     if func in (os.rmdir, os.remove, os.unlink) and excvalue.errno == errno.EACCES:
-        os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)  # 0777
+        Path(path).chmod(stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)  # 0777
         func(path)
     else:
         raise
@@ -242,7 +242,7 @@ def get_git_objects_dir(path: Path) -> Path:
     ).absolute()
 
 
-def set_git_alternates(*repos: Path, path: Path = Path(".")) -> None:
+def set_git_alternates(*repos: Path, path: Path = Path()) -> None:
     """Set Git alternates to borrow Git objects from other repositories.
 
     Alternates are paths of other repositories' object directories written to
diff --git a/copier/vcs.py b/copier/vcs.py
index 8fdd2e6..1917a7f 100644
--- a/copier/vcs.py
+++ b/copier/vcs.py
@@ -2,7 +2,6 @@
 
 from __future__ import annotations
 
-import os
 import re
 import sys
 from contextlib import suppress
@@ -190,12 +189,12 @@ def clone(url: str, ref: str | None = None) -> str:
             _clone = _clone["--filter=blob:none"]
     _clone()
     # Include dirty changes if checking out a local HEAD
-    if ref in {None, "HEAD"} and os.path.exists(url) and Path(url).is_dir():
+    url_abspath = Path(url).absolute()
+    if ref in {None, "HEAD"} and url_abspath.is_dir():
         is_dirty = False
         with local.cwd(url):
             is_dirty = bool(git("status", "--porcelain").strip())
         if is_dirty:
-            url_abspath = Path(url).absolute()
             with local.cwd(location):
                 git("--git-dir=.git", f"--work-tree={url_abspath}", "add", "-A")
                 git(
diff --git a/poetry.lock b/poetry.lock
index 8884dd7..36c40b1 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -634,13 +634,13 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp
 
 [[package]]
 name = "mkdocs-autorefs"
-version = "1.3.0"
+version = "1.4.0"
 description = "Automatically link across pages in MkDocs."
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "mkdocs_autorefs-1.3.0-py3-none-any.whl", hash = "sha256:d180f9778a04e78b7134e31418f238bba56f56d6a8af97873946ff661befffb3"},
-    {file = "mkdocs_autorefs-1.3.0.tar.gz", hash = "sha256:6867764c099ace9025d6ac24fd07b85a98335fbd30107ef01053697c8f46db61"},
+    {file = "mkdocs_autorefs-1.4.0-py3-none-any.whl", hash = "sha256:bad19f69655878d20194acd0162e29a89c3f7e6365ffe54e72aa3fd1072f240d"},
+    {file = "mkdocs_autorefs-1.4.0.tar.gz", hash = "sha256:a9c0aa9c90edbce302c09d050a3c4cb7c76f8b7b2c98f84a7a05f53d00392156"},
 ]
 
 [package.dependencies]
@@ -707,13 +707,13 @@ files = [
 
 [[package]]
 name = "mkdocstrings"
-version = "0.28.1"
+version = "0.28.2"
 description = "Automatic documentation from sources, for MkDocs."
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "mkdocstrings-0.28.1-py3-none-any.whl", hash = "sha256:a5878ae5cd1e26f491ff084c1f9ab995687d52d39a5c558e9b7023d0e4e0b740"},
-    {file = "mkdocstrings-0.28.1.tar.gz", hash = "sha256:fb64576906771b7701e8e962fd90073650ff689e95eb86e86751a66d65ab4489"},
+    {file = "mkdocstrings-0.28.2-py3-none-any.whl", hash = "sha256:57f79c557e2718d217d6f6a81bf75a0de097f10e922e7e5e00f085c3f0ff6895"},
+    {file = "mkdocstrings-0.28.2.tar.gz", hash = "sha256:9b847266d7a588ea76a8385eaebe1538278b4361c0d1ce48ed005be59f053569"},
 ]
 
 [package.dependencies]
@@ -722,7 +722,7 @@ Jinja2 = ">=2.11.1"
 Markdown = ">=3.6"
 MarkupSafe = ">=1.1"
 mkdocs = ">=1.4"
-mkdocs-autorefs = ">=1.3"
+mkdocs-autorefs = ">=1.4"
 mkdocs-get-deps = ">=0.2"
 mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""}
 pymdown-extensions = ">=6.3"
diff --git a/pyproject.toml b/pyproject.toml
index 9b1dcc6..110802c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -118,6 +118,7 @@ extend-select = [
   "I",
   "PERF",
   "PGH",
+  "PTH",
   "UP",
 ]
 extend-ignore = ['B028', "B904", "D105", "D107", "E501"]


Test patch:
diff --git a/tests/demo_legacy_migrations/migrations.py b/tests/demo_legacy_migrations/migrations.py
index 9f45c99..486fd78 100755
--- a/tests/demo_legacy_migrations/migrations.py
+++ b/tests/demo_legacy_migrations/migrations.py
@@ -2,6 +2,7 @@
 import json
 import os
 import sys
+from pathlib import Path
 
 NAMES = (
     "{VERSION_FROM}-{VERSION_CURRENT}-{VERSION_TO}-{STAGE}.json",
@@ -9,5 +10,5 @@ NAMES = (
 )
 
 for name in NAMES:
-    with open(name.format(**os.environ), "w") as fd:
+    with Path(name.format(**os.environ)).open("w") as fd:
         json.dump(sys.argv, fd)
diff --git a/tests/demo_legacy_migrations/tasks.py b/tests/demo_legacy_migrations/tasks.py
index a046b97..34ebd8f 100755
--- a/tests/demo_legacy_migrations/tasks.py
+++ b/tests/demo_legacy_migrations/tasks.py
@@ -1,10 +1,10 @@
 #!/usr/bin/env python
 import os
-import os.path
 import sys
 from contextlib import suppress
+from pathlib import Path
 
-with open("created-with-tasks.txt", "a", newline="\n") as cwt:
+with Path("created-with-tasks.txt").open("a", newline="\n") as cwt:
     cwt.write(" ".join([os.environ["STAGE"]] + sys.argv[1:]) + "\n")
     with suppress(FileNotFoundError):
-        os.unlink("delete-in-tasks.txt")
+        Path("delete-in-tasks.txt").unlink()
diff --git a/tests/test_config.py b/tests/test_config.py
index 1546cd4..53b2644 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -329,7 +329,7 @@ def test_flags_extra_fails() -> None:
     with pytest.raises(ValidationError):
         copier.Worker(  # type: ignore[call-arg]
             src_path="..",
-            dst_path=Path("."),
+            dst_path=Path(),
             i_am_not_a_member="and_i_do_not_belong_here",
         )
 
diff --git a/tests/test_copy.py b/tests/test_copy.py
index c98060a..8e442b8 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import filecmp
-import os
 import platform
 import stat
 import sys
@@ -302,10 +301,10 @@ def test_skip_if_exists(tmp_path: Path) -> None:
 
 def test_timestamp_identical(tmp_path: Path) -> None:
     copier.run_copy(str(Path("tests", "demo_skip_dst")), tmp_path)
-    modification_time_before = os.path.getmtime(tmp_path / "a.noeof.txt")
+    modification_time_before = (tmp_path / "a.noeof.txt").stat().st_mtime
     sleep(2)
     copier.run_copy(str(Path("tests", "demo_skip_dst")), tmp_path)
-    modification_time_after = os.path.getmtime(tmp_path / "a.noeof.txt")
+    modification_time_after = (tmp_path / "a.noeof.txt").stat().st_mtime
 
     assert modification_time_before == modification_time_after
 
diff --git a/tests/test_dirty_local.py b/tests/test_dirty_local.py
index 9b210bc..d34b087 100644
--- a/tests/test_dirty_local.py
+++ b/tests/test_dirty_local.py
@@ -122,7 +122,7 @@ def test_update(tmp_path_factory: pytest.TempPathFactory) -> None:
         Path("test_file.txt").write_text("Test content")
 
         # test updating a file
-        with open("aaaa.txt", "a") as f:
+        with Path("aaaa.txt").open("a") as f:
             f.write("dolor sit amet")
 
         # test updating a symlink
diff --git a/tests/test_subdirectory.py b/tests/test_subdirectory.py
index 0a1ea60..5a3393b 100644
--- a/tests/test_subdirectory.py
+++ b/tests/test_subdirectory.py
@@ -21,6 +21,13 @@ def template_path(tmp_path_factory: pytest.TempPathFactory) -> str:
                 "[[ _copier_answers|to_nice_yaml ]]"
             ),
             (root / "conf_project" / "conf_readme.md"): (
+                """\
+                # Template subdirectory
+
+                This is the template README.
+                """
+            ),
+            (root / "conf_project" / "conf_readme.md.tmpl"): (
                 """\
                 # Demo subdirectory
 
@@ -30,6 +37,9 @@ def template_path(tmp_path_factory: pytest.TempPathFactory) -> str:
             (root / "conf_project" / "[[ _copier_conf.answers_file ]].tmpl"): (
                 "[[ _copier_answers|to_nice_yaml ]]"
             ),
+            (root / "conf_project" / "[[ filename ]].tmpl"): (
+                "[[ filename ]] contents"
+            ),
             (root / "copier.yml"): (
                 f"""\
                 _templates_suffix: {SUFFIX_TMPL}
@@ -41,6 +51,9 @@ def template_path(tmp_path_factory: pytest.TempPathFactory) -> str:
                         - api_project
                         - conf_project
                 _subdirectory: "[[ choose_subdir ]]"
+                filename:
+                    type: str
+                    default: mock_filename
                 """
             ),
         }
@@ -68,6 +81,18 @@ def test_copy_subdirectory_config(template_path: str, tmp_path: Path) -> None:
     assert not (tmp_path / "api_readme.md").exists()
 
 
+def test_copy_subdirectory_config_no_overwrite(
+    template_path: str, tmp_path: Path
+) -> None:
+    copier.run_copy(template_path, tmp_path, defaults=True, overwrite=False)
+    assert (tmp_path / "conf_readme.md").exists()
+    assert (tmp_path / "mock_filename").exists()
+    assert "mock_filename contents" in (tmp_path / "mock_filename").read_text()
+    assert "# Demo subdirectory" in (tmp_path / "conf_readme.md").read_text()
+    assert "# Template subdirectory" not in (tmp_path / "conf_readme.md").read_text()
+    assert not (tmp_path / "api_readme.md").exists()
+
+
 def test_update_subdirectory(template_path: str, tmp_path: Path) -> None:
     copier.run_copy(template_path, tmp_path, defaults=True, overwrite=True)
 
diff --git a/tests/test_symlinks.py b/tests/test_symlinks.py
index 40a942e..a652803 100644
--- a/tests/test_symlinks.py
+++ b/tests/test_symlinks.py
@@ -38,9 +38,9 @@ def test_copy_symlink(tmp_path_factory: pytest.TempPathFactory) -> None:
         vcs_ref="HEAD",
     )
 
-    assert os.path.exists(dst / "target.txt")
-    assert os.path.exists(dst / "symlink.txt")
-    assert os.path.islink(dst / "symlink.txt")
+    assert (dst / "target.txt").exists()
+    assert (dst / "symlink.txt").exists()
+    assert (dst / "symlink.txt").is_symlink()
     assert (dst / "symlink.txt").readlink() == Path("target.txt")
 
 
@@ -73,9 +73,9 @@ def test_copy_symlink_templated_name(tmp_path_factory: pytest.TempPathFactory) -
         vcs_ref="HEAD",
     )
 
-    assert os.path.exists(dst / "target.txt")
-    assert os.path.exists(dst / "symlink.txt")
-    assert os.path.islink(dst / "symlink.txt")
+    assert (dst / "target.txt").exists()
+    assert (dst / "symlink.txt").exists()
+    assert (dst / "symlink.txt").is_symlink()
     assert (dst / "symlink.txt").readlink() == Path("target.txt")
 
 
@@ -111,14 +111,13 @@ def test_copy_symlink_templated_target(
         vcs_ref="HEAD",
     )
 
-    assert os.path.exists(dst / "target.txt")
-
-    assert os.path.exists(dst / "symlink1.txt")
-    assert os.path.islink(dst / "symlink1.txt")
+    assert (dst / "target.txt").exists()
+    assert (dst / "symlink1.txt").exists()
+    assert (dst / "symlink1.txt").is_symlink()
     assert (dst / "symlink1.txt").readlink() == Path("target.txt")
 
-    assert not os.path.exists(dst / "symlink2.txt")
-    assert os.path.islink(dst / "symlink2.txt")
+    assert not (dst / "symlink2.txt").exists()
+    assert (dst / "symlink2.txt").is_symlink()
     assert (dst / "symlink2.txt").readlink() == Path("{{ target_name }}.txt")
 
 
@@ -149,11 +148,11 @@ def test_copy_symlink_missing_target(tmp_path_factory: pytest.TempPathFactory) -
         vcs_ref="HEAD",
     )
 
-    assert os.path.islink(dst / "symlink.txt")
+    assert (dst / "symlink.txt").is_symlink()
     assert (dst / "symlink.txt").readlink() == Path("target.txt")
-    assert not os.path.exists(
+    assert not (
         dst / "symlink.txt"
-    )  # exists follows symlinks, It returns False as the target doesn't exist
+    ).exists()  # exists follows symlinks, It returns False as the target doesn't exist
 
 
 def test_option_preserve_symlinks_false(
@@ -186,9 +185,9 @@ def test_option_preserve_symlinks_false(
         vcs_ref="HEAD",
     )
 
-    assert os.path.exists(dst / "target.txt")
-    assert os.path.exists(dst / "symlink.txt")
-    assert not os.path.islink(dst / "symlink.txt")
+    assert (dst / "target.txt").exists()
+    assert (dst / "symlink.txt").exists()
+    assert not (dst / "symlink.txt").is_symlink()
 
 
 def test_option_preserve_symlinks_default(
@@ -220,9 +219,9 @@ def test_option_preserve_symlinks_default(
         vcs_ref="HEAD",
     )
 
-    assert os.path.exists(dst / "target.txt")
-    assert os.path.exists(dst / "symlink.txt")
-    assert not os.path.islink(dst / "symlink.txt")
+    assert (dst / "target.txt").exists()
+    assert (dst / "symlink.txt").exists()
+    assert not (dst / "symlink.txt").is_symlink()
 
 
 def test_update_symlink(tmp_path_factory: pytest.TempPathFactory) -> None:
@@ -256,7 +255,7 @@ def test_update_symlink(tmp_path_factory: pytest.TempPathFactory) -> None:
 
     with local.cwd(src):
         # test updating a symlink
-        os.remove("symlink.txt")
+        Path("symlink.txt").unlink()
         os.symlink("bbbb.txt", "symlink.txt")
 
     # dst must be vcs-tracked to use run_update
@@ -312,10 +311,10 @@ def test_update_file_to_symlink(tmp_path_factory: pytest.TempPathFactory) -> Non
 
     with local.cwd(src):
         # test updating a symlink
-        os.remove("bbbb.txt")
+        Path("bbbb.txt").unlink()
         os.symlink("aaaa.txt", "bbbb.txt")
-        os.remove("cccc.txt")
-        with open("cccc.txt", "w+") as f:
+        Path("cccc.txt").unlink()
+        with Path("cccc.txt").open("w+") as f:
             f.write("Lorem ipsum")
 
     # dst must be vcs-tracked to use run_update
@@ -431,9 +430,9 @@ def test_copy_symlink_none_path(tmp_path_factory: pytest.TempPathFactory) -> Non
         vcs_ref="HEAD",
     )
 
-    assert os.path.exists(dst / "target.txt")
-    assert not os.path.exists(dst / "symlink.txt")
-    assert not os.path.islink(dst / "symlink.txt")
+    assert (dst / "target.txt").exists()
+    assert not (dst / "symlink.txt").exists()
+    assert not (dst / "symlink.txt").is_symlink()
 
 
 def test_recursive_symlink(tmp_path_factory: pytest.TempPathFactory) -> None:
diff --git a/tests/test_tools.py b/tests/test_tools.py
index e48c825..0bc6794 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -12,7 +12,7 @@ from .helpers import git
 
 def test_types() -> None:
     """Ensure source code static typing."""
-    result = PoeThePoet(Path("."))(["types"])
+    result = PoeThePoet(Path())(["types"])
     assert result == 0