Repository: copier-org/copier
Pull request #1989 (2025-02-26 16:09:30)

build(deps): drop unused `types-psutil` dependency
I've dropped the `types-psutil` dev dependency, as it doesn't seem needed anywhere – `mypy` is passing locally for me without it being installed.

This PR will render #1971 obsolete.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/copier-org/copier/pull/1989?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 94.65%. Comparing base [(`35f8891`)](https://app.codecov.io/gh/copier-org/copier/commit/35f889111676a588caa7a39aba849e5b5b07e4eb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) to head [(`1fdd9d0`)](https://app.codecov.io/gh/copier-org/copier/commit/1fdd9d0cbd6dd401a1fdf54cbb4d543de0f47791?dropdown=coverage&el=desc&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    #1989      +/-   ##
==========================================
- Coverage   98.06%   94.65%   -3.41%     
==========================================
  Files          53       53              
  Lines        5575     5575              
==========================================
- Hits         5467     5277     -190     
- Misses        108      298     +190     
```

| [Flag](https://app.codecov.io/gh/copier-org/copier/pull/1989/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/1989/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=copier-org) | `94.65% <ø> (-3.41%)` | :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/1989?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 36c40b1..be8dc4c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1577,17 +1577,6 @@ files = [
     {file = "types_docutils-0.21.0.20241005-py3-none-any.whl", hash = "sha256:4d9021422f2f3fca8b0726fb8949395f66a06c0d951479eb3b1387d75b134430"},
 ]
 
-[[package]]
-name = "types-psutil"
-version = "6.1.0.20241221"
-description = "Typing stubs for psutil"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "types_psutil-6.1.0.20241221-py3-none-any.whl", hash = "sha256:8498dbe13285a9ba7d4b2fa934c569cc380efc74e3dacdb34ae16d2cdf389ec3"},
-    {file = "types_psutil-6.1.0.20241221.tar.gz", hash = "sha256:600f5a36bd5e0eb8887f0e3f3ff2cf154d90690ad8123c8a707bba4ab94d3185"},
-]
-
 [[package]]
 name = "types-pygments"
 version = "2.19.0.20250219"
@@ -1748,4 +1737,4 @@ type = ["pytest-mypy"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.9"
-content-hash = "3c9bb33fa0a43bd04125dbd879cef233ac4e3582431b3e06817f77bc9da84920"
+content-hash = "8778772793661c235a48d4ab2de8839426451f751ca66a79387790f5067c2794"
diff --git a/pyproject.toml b/pyproject.toml
index 9b1dcc6..32c1cc9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -57,7 +57,6 @@ pytest-gitconfig = ">=0.6.0"
 pytest-xdist = ">=2.5.0"
 types-backports = ">=0.1.3"
 types-colorama = ">=0.4"
-types-psutil = ">=0"
 types-pygments = ">=2.17"
 types-pyyaml = ">=6.0.4"
 typing-extensions = { version = ">=3.10.0.0,<5.0.0", python = "<3.10" }
@@ -118,6 +117,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