https://github.com/python/cpython/commit/625d0705b9ab348f85853ed73b796763b70154a5
commit: 625d0705b9ab348f85853ed73b796763b70154a5
branch: main
author: Barney Gale <[email protected]>
committer: barneygale <[email protected]>
date: 2024-08-25T16:51:51+01:00
summary:

GH-73991: Add `pathlib.Path.move()` (#122073)

Add a `Path.move()` method that moves a file or directory tree, and returns a 
new `Path` instance pointing to the target.

This method is similar to `shutil.move()`, except that it doesn't accept a 
*copy_function* argument, and it doesn't check whether the destination is an 
existing directory.

files:
A Misc/NEWS.d/next/Library/2024-07-21-02-00-46.gh-issue-73991.pLxdtJ.rst
M Doc/library/pathlib.rst
M Doc/whatsnew/3.14.rst
M Lib/pathlib/_abc.py
M Lib/test/test_pathlib/test_pathlib.py
M Lib/test/test_pathlib/test_pathlib_abc.py

diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst
index f66d36a32cbd04..9f5f10a087243b 100644
--- a/Doc/library/pathlib.rst
+++ b/Doc/library/pathlib.rst
@@ -1536,8 +1536,8 @@ Creating files and directories
       available. In previous versions, :exc:`NotImplementedError` was raised.
 
 
-Copying, renaming and deleting
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Copying, moving and deleting
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 .. method:: Path.copy(target, *, follow_symlinks=True, dirs_exist_ok=False, \
                       preserve_metadata=False, ignore=None, on_error=None)
@@ -1616,6 +1616,23 @@ Copying, renaming and deleting
       Added return value, return the new :class:`!Path` instance.
 
 
+.. method:: Path.move(target)
+
+   Move this file or directory tree to the given *target*, and return a new
+   :class:`!Path` instance pointing to *target*.
+
+   If the *target* doesn't exist it will be created. If both this path and the
+   *target* are existing files, then the target is overwritten. If both paths
+   point to the same file or directory, or the *target* is a non-empty
+   directory, then :exc:`OSError` is raised.
+
+   If both paths are on the same filesystem, the move is performed with
+   :func:`os.replace`. Otherwise, this path is copied (preserving metadata and
+   symlinks) and then deleted.
+
+   .. versionadded:: 3.14
+
+
 .. method:: Path.unlink(missing_ok=False)
 
    Remove this file or symbolic link.  If the path points to a directory,
diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst
index 17d6a68d1b311f..e5c0fda7a91fd2 100644
--- a/Doc/whatsnew/3.14.rst
+++ b/Doc/whatsnew/3.14.rst
@@ -185,10 +185,13 @@ os
 pathlib
 -------
 
-* Add methods to :class:`pathlib.Path` to recursively copy or remove files:
+* 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.delete` removes a file or directory tree.
 
   (Contributed by Barney Gale in :gh:`73991`.)
diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py
index 9943ea4d14148e..93758b1c71c62b 100644
--- a/Lib/pathlib/_abc.py
+++ b/Lib/pathlib/_abc.py
@@ -14,7 +14,7 @@
 import functools
 import operator
 import posixpath
-from errno import EINVAL
+from errno import EINVAL, EXDEV
 from glob import _GlobberBase, _no_recurse_symlinks
 from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, 
S_ISFIFO
 from pathlib._os import copyfileobj
@@ -928,6 +928,25 @@ def replace(self, target):
         """
         raise UnsupportedOperation(self._unsupported_msg('replace()'))
 
+    def move(self, target):
+        """
+        Recursively move this file or directory tree to the given destination.
+        """
+        self._ensure_different_file(target)
+        try:
+            return self.replace(target)
+        except UnsupportedOperation:
+            pass
+        except TypeError:
+            if not isinstance(target, PathBase):
+                raise
+        except OSError as err:
+            if err.errno != EXDEV:
+                raise
+        target = self.copy(target, follow_symlinks=False, 
preserve_metadata=True)
+        self.delete()
+        return target
+
     def chmod(self, mode, *, follow_symlinks=True):
         """
         Change the permissions of the path, like os.chmod().
diff --git a/Lib/test/test_pathlib/test_pathlib.py 
b/Lib/test/test_pathlib/test_pathlib.py
index ad1720cdb24f0b..4d38246dbb3853 100644
--- a/Lib/test/test_pathlib/test_pathlib.py
+++ b/Lib/test/test_pathlib/test_pathlib.py
@@ -45,6 +45,19 @@
     {os.open, os.stat, os.unlink, os.rmdir} <= os.supports_dir_fd and
     os.listdir in os.supports_fd and os.stat in os.supports_follow_symlinks)
 
+def patch_replace(old_test):
+    def new_replace(self, target):
+        raise OSError(errno.EXDEV, "Cross-device link", self, target)
+
+    def new_test(self):
+        old_replace = self.cls.replace
+        self.cls.replace = new_replace
+        try:
+            old_test(self)
+        finally:
+            self.cls.replace = old_replace
+    return new_test
+
 #
 # Tests for the pure classes.
 #
@@ -799,6 +812,55 @@ def test_copy_dir_preserve_metadata_xattrs(self):
         target_file = target.joinpath('dirD', 'fileD')
         self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42')
 
+    @patch_replace
+    def test_move_file_other_fs(self):
+        self.test_move_file()
+
+    @patch_replace
+    def test_move_file_to_file_other_fs(self):
+        self.test_move_file_to_file()
+
+    @patch_replace
+    def test_move_file_to_dir_other_fs(self):
+        self.test_move_file_to_dir()
+
+    @patch_replace
+    def test_move_dir_other_fs(self):
+        self.test_move_dir()
+
+    @patch_replace
+    def test_move_dir_to_dir_other_fs(self):
+        self.test_move_dir_to_dir()
+
+    @patch_replace
+    def test_move_dir_into_itself_other_fs(self):
+        self.test_move_dir_into_itself()
+
+    @patch_replace
+    @needs_symlinks
+    def test_move_file_symlink_other_fs(self):
+        self.test_move_file_symlink()
+
+    @patch_replace
+    @needs_symlinks
+    def test_move_file_symlink_to_itself_other_fs(self):
+        self.test_move_file_symlink_to_itself()
+
+    @patch_replace
+    @needs_symlinks
+    def test_move_dir_symlink_other_fs(self):
+        self.test_move_dir_symlink()
+
+    @patch_replace
+    @needs_symlinks
+    def test_move_dir_symlink_to_itself_other_fs(self):
+        self.test_move_dir_symlink_to_itself()
+
+    @patch_replace
+    @needs_symlinks
+    def test_move_dangling_symlink_other_fs(self):
+        self.test_move_dangling_symlink()
+
     def test_resolve_nonexist_relative_issue38671(self):
         p = self.cls('non', 'exist')
 
diff --git a/Lib/test/test_pathlib/test_pathlib_abc.py 
b/Lib/test/test_pathlib/test_pathlib_abc.py
index 5b714756e95e10..7f8f614301608f 100644
--- a/Lib/test/test_pathlib/test_pathlib_abc.py
+++ b/Lib/test/test_pathlib/test_pathlib_abc.py
@@ -2072,6 +2072,125 @@ def test_copy_dangling_symlink(self):
         self.assertTrue(target2.joinpath('link').is_symlink())
         self.assertEqual(target2.joinpath('link').readlink(), 
self.cls('nonexistent'))
 
+    def test_move_file(self):
+        base = self.cls(self.base)
+        source = base / 'fileA'
+        source_text = source.read_text()
+        target = base / 'fileA_moved'
+        result = source.move(target)
+        self.assertEqual(result, target)
+        self.assertFalse(source.exists())
+        self.assertTrue(target.exists())
+        self.assertEqual(source_text, target.read_text())
+
+    def test_move_file_to_file(self):
+        base = self.cls(self.base)
+        source = base / 'fileA'
+        source_text = source.read_text()
+        target = base / 'dirB' / 'fileB'
+        result = source.move(target)
+        self.assertEqual(result, target)
+        self.assertFalse(source.exists())
+        self.assertTrue(target.exists())
+        self.assertEqual(source_text, target.read_text())
+
+    def test_move_file_to_dir(self):
+        base = self.cls(self.base)
+        source = base / 'fileA'
+        target = base / 'dirB'
+        self.assertRaises(OSError, source.move, target)
+
+    def test_move_file_to_itself(self):
+        base = self.cls(self.base)
+        source = base / 'fileA'
+        self.assertRaises(OSError, source.move, source)
+
+    def test_move_dir(self):
+        base = self.cls(self.base)
+        source = base / 'dirC'
+        target = base / 'dirC_moved'
+        result = source.move(target)
+        self.assertEqual(result, target)
+        self.assertFalse(source.exists())
+        self.assertTrue(target.is_dir())
+        self.assertTrue(target.joinpath('dirD').is_dir())
+        self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
+        self.assertEqual(target.joinpath('dirD', 'fileD').read_text(),
+                         "this is file D\n")
+        self.assertTrue(target.joinpath('fileC').is_file())
+        self.assertTrue(target.joinpath('fileC').read_text(),
+                        "this is file C\n")
+
+    def test_move_dir_to_dir(self):
+        base = self.cls(self.base)
+        source = base / 'dirC'
+        target = base / 'dirB'
+        self.assertRaises(OSError, source.move, target)
+        self.assertTrue(source.exists())
+        self.assertTrue(target.exists())
+
+    def test_move_dir_to_itself(self):
+        base = self.cls(self.base)
+        source = base / 'dirC'
+        self.assertRaises(OSError, source.move, source)
+        self.assertTrue(source.exists())
+
+    def test_move_dir_into_itself(self):
+        base = self.cls(self.base)
+        source = base / 'dirC'
+        target = base / 'dirC' / 'bar'
+        self.assertRaises(OSError, source.move, target)
+        self.assertTrue(source.exists())
+        self.assertFalse(target.exists())
+
+    @needs_symlinks
+    def test_move_file_symlink(self):
+        base = self.cls(self.base)
+        source = base / 'linkA'
+        source_readlink = source.readlink()
+        target = base / 'linkA_moved'
+        result = source.move(target)
+        self.assertEqual(result, target)
+        self.assertFalse(source.exists())
+        self.assertTrue(target.is_symlink())
+        self.assertEqual(source_readlink, target.readlink())
+
+    @needs_symlinks
+    def test_move_file_symlink_to_itself(self):
+        base = self.cls(self.base)
+        source = base / 'linkA'
+        self.assertRaises(OSError, source.move, source)
+
+    @needs_symlinks
+    def test_move_dir_symlink(self):
+        base = self.cls(self.base)
+        source = base / 'linkB'
+        source_readlink = source.readlink()
+        target = base / 'linkB_moved'
+        result = source.move(target)
+        self.assertEqual(result, target)
+        self.assertFalse(source.exists())
+        self.assertTrue(target.is_symlink())
+        self.assertEqual(source_readlink, target.readlink())
+
+    @needs_symlinks
+    def test_move_dir_symlink_to_itself(self):
+        base = self.cls(self.base)
+        source = base / 'linkB'
+        self.assertRaises(OSError, source.move, source)
+
+    @needs_symlinks
+    def test_move_dangling_symlink(self):
+        base = self.cls(self.base)
+        source = base / 'brokenLink'
+        source_readlink = source.readlink()
+        target = base / 'brokenLink_moved'
+        result = source.move(target)
+        self.assertEqual(result, target)
+        self.assertFalse(source.exists())
+        self.assertTrue(target.is_symlink())
+        self.assertEqual(source_readlink, target.readlink())
+
     def test_iterdir(self):
         P = self.cls
         p = P(self.base)
diff --git 
a/Misc/NEWS.d/next/Library/2024-07-21-02-00-46.gh-issue-73991.pLxdtJ.rst 
b/Misc/NEWS.d/next/Library/2024-07-21-02-00-46.gh-issue-73991.pLxdtJ.rst
new file mode 100644
index 00000000000000..26fdd8c59b1c50
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-07-21-02-00-46.gh-issue-73991.pLxdtJ.rst
@@ -0,0 +1 @@
+Add :meth:`pathlib.Path.move`, which moves a file or directory tree.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: [email protected]

Reply via email to