]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-73991: Add `pathlib.Path.copy_into()` and `move_into()` (#123314)
authorBarney Gale <barney.gale@gmail.com>
Mon, 26 Aug 2024 13:14:23 +0000 (14:14 +0100)
committerGitHub <noreply@github.com>
Mon, 26 Aug 2024 13:14:23 +0000 (14:14 +0100)
These two methods accept an *existing* directory path, onto which we join
the source path's base name to form the final target path.

A possible alternative implementation is to check for directories in
`copy()` and `move()` and adjust the target path, which is done in several
`shutil` functions. This behaviour is helpful in a shell context, but
less so in a stored program that explicitly specifies destinations. For
example, a user that calls `Path('foo.py').copy('bar.py')` might not
imagine that `bar.py/foo.py` would be created, but under the alternative
implementation this will happen if `bar.py` is an existing directory.

Doc/library/pathlib.rst
Doc/whatsnew/3.14.rst
Lib/pathlib/_abc.py
Lib/test/test_pathlib/test_pathlib.py
Lib/test/test_pathlib/test_pathlib_abc.py
Misc/NEWS.d/next/Library/2024-08-25-16-59-20.gh-issue-73991.1w8u3K.rst [new file with mode: 0644]

index 9f5f10a087243bac40b67f95137e7a8b78aa5586..0b6a6a3f575d43e955b483ab3874dd8096de321d 100644 (file)
@@ -1575,6 +1575,18 @@ Copying, moving and deleting
    .. versionadded:: 3.14
 
 
+.. method:: Path.copy_into(target_dir, *, follow_symlinks=True, \
+                           dirs_exist_ok=False, preserve_metadata=False, \
+                           ignore=None, on_error=None)
+
+   Copy this file or directory tree into the given *target_dir*, which should
+   be an existing directory. Other arguments are handled identically to
+   :meth:`Path.copy`. Returns a new :class:`!Path` instance pointing to the
+   copy.
+
+   .. versionadded:: 3.14
+
+
 .. method:: Path.rename(target)
 
    Rename this file or directory to the given *target*, and return a new
@@ -1633,6 +1645,15 @@ Copying, moving and deleting
    .. versionadded:: 3.14
 
 
+.. method:: Path.move_into(target_dir)
+
+   Move this file or directory tree into the given *target_dir*, which should
+   be an existing directory. Returns a new :class:`!Path` instance pointing to
+   the moved path.
+
+   .. versionadded:: 3.14
+
+
 .. method:: Path.unlink(missing_ok=False)
 
    Remove this file or symbolic link.  If the path points to a directory,
index e5c0fda7a91fd29a3162f920290d0414cb6a8a4c..34434e4fb52873ce0f15c55a3734aefa80279dcd 100644 (file)
@@ -188,10 +188,10 @@ pathlib
 * Add methods to :class:`pathlib.Path` to recursively copy, move, or remove
   files and directories:
 
-  * :meth:`~pathlib.Path.copy` copies a file or directory tree to a given
-    destination.
-  * :meth:`~pathlib.Path.move` moves a file or directory tree to a given
-    destination.
+  * :meth:`~pathlib.Path.copy` copies a file or directory tree to a destination.
+  * :meth:`~pathlib.Path.copy_into` copies *into* a destination directory.
+  * :meth:`~pathlib.Path.move` moves a file or directory tree to a destination.
+  * :meth:`~pathlib.Path.move_into` moves *into* a destination directory.
   * :meth:`~pathlib.Path.delete` removes a file or directory tree.
 
   (Contributed by Barney Gale in :gh:`73991`.)
index 93758b1c71c62bdeb359da323afe1a17bd431d48..0c76480063eccaa4d33db49ef62afe8d73d79e34 100644 (file)
@@ -904,6 +904,24 @@ class PathBase(PurePathBase):
                 on_error(err)
         return target
 
+    def copy_into(self, target_dir, *, follow_symlinks=True,
+                  dirs_exist_ok=False, preserve_metadata=False, ignore=None,
+                  on_error=None):
+        """
+        Copy this file or directory tree into the given existing directory.
+        """
+        name = self.name
+        if not name:
+            raise ValueError(f"{self!r} has an empty name")
+        elif isinstance(target_dir, PathBase):
+            target = target_dir / name
+        else:
+            target = self.with_segments(target_dir, name)
+        return self.copy(target, follow_symlinks=follow_symlinks,
+                         dirs_exist_ok=dirs_exist_ok,
+                         preserve_metadata=preserve_metadata, ignore=ignore,
+                         on_error=on_error)
+
     def rename(self, target):
         """
         Rename this path to the target path.
@@ -947,6 +965,19 @@ class PathBase(PurePathBase):
         self.delete()
         return target
 
+    def move_into(self, target_dir):
+        """
+        Move this file or directory tree into the given existing directory.
+        """
+        name = self.name
+        if not name:
+            raise ValueError(f"{self!r} has an empty name")
+        elif isinstance(target_dir, PathBase):
+            target = target_dir / name
+        else:
+            target = self.with_segments(target_dir, name)
+        return self.move(target)
+
     def chmod(self, mode, *, follow_symlinks=True):
         """
         Change the permissions of the path, like os.chmod().
index 4d38246dbb3853925de149a69e85d33ebce26e32..080b8df1c705fc475dcd69efb8e188b2be22db0a 100644 (file)
@@ -861,6 +861,14 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest):
     def test_move_dangling_symlink_other_fs(self):
         self.test_move_dangling_symlink()
 
+    @patch_replace
+    def test_move_into_other_os(self):
+        self.test_move_into()
+
+    @patch_replace
+    def test_move_into_empty_name_other_os(self):
+        self.test_move_into_empty_name()
+
     def test_resolve_nonexist_relative_issue38671(self):
         p = self.cls('non', 'exist')
 
index 7f8f614301608ff89b02b3f955a6ccc96ac314d2..4a32cb9c8367e42eb10d10cfd48b31801cfded85 100644 (file)
@@ -2072,6 +2072,20 @@ class DummyPathTest(DummyPurePathTest):
         self.assertTrue(target2.joinpath('link').is_symlink())
         self.assertEqual(target2.joinpath('link').readlink(), self.cls('nonexistent'))
 
+    def test_copy_into(self):
+        base = self.cls(self.base)
+        source = base / 'fileA'
+        target_dir = base / 'dirA'
+        result = source.copy_into(target_dir)
+        self.assertEqual(result, target_dir / 'fileA')
+        self.assertTrue(result.exists())
+        self.assertEqual(source.read_text(), result.read_text())
+
+    def test_copy_into_empty_name(self):
+        source = self.cls('')
+        target_dir = self.base
+        self.assertRaises(ValueError, source.copy_into, target_dir)
+
     def test_move_file(self):
         base = self.cls(self.base)
         source = base / 'fileA'
@@ -2191,6 +2205,22 @@ class DummyPathTest(DummyPurePathTest):
         self.assertTrue(target.is_symlink())
         self.assertEqual(source_readlink, target.readlink())
 
+    def test_move_into(self):
+        base = self.cls(self.base)
+        source = base / 'fileA'
+        source_text = source.read_text()
+        target_dir = base / 'dirA'
+        result = source.move_into(target_dir)
+        self.assertEqual(result, target_dir / 'fileA')
+        self.assertFalse(source.exists())
+        self.assertTrue(result.exists())
+        self.assertEqual(source_text, result.read_text())
+
+    def test_move_into_empty_name(self):
+        source = self.cls('')
+        target_dir = self.base
+        self.assertRaises(ValueError, source.move_into, target_dir)
+
     def test_iterdir(self):
         P = self.cls
         p = P(self.base)
diff --git a/Misc/NEWS.d/next/Library/2024-08-25-16-59-20.gh-issue-73991.1w8u3K.rst b/Misc/NEWS.d/next/Library/2024-08-25-16-59-20.gh-issue-73991.1w8u3K.rst
new file mode 100644 (file)
index 0000000..4ad5a06
--- /dev/null
@@ -0,0 +1,2 @@
+Add :meth:`pathlib.Path.copy_into` and :meth:`~pathlib.Path.move_into`,
+which copy and move files and directories into *existing* directories.