Skip to content

Commit 625d070

Browse files
authored
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.
1 parent aa90592 commit 625d070

File tree

6 files changed

+225
-4
lines changed

6 files changed

+225
-4
lines changed

Doc/library/pathlib.rst

+19-2
Original file line numberDiff line numberDiff line change
@@ -1536,8 +1536,8 @@ Creating files and directories
15361536
available. In previous versions, :exc:`NotImplementedError` was raised.
15371537

15381538

1539-
Copying, renaming and deleting
1540-
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1539+
Copying, moving and deleting
1540+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
15411541

15421542
.. method:: Path.copy(target, *, follow_symlinks=True, dirs_exist_ok=False, \
15431543
preserve_metadata=False, ignore=None, on_error=None)
@@ -1616,6 +1616,23 @@ Copying, renaming and deleting
16161616
Added return value, return the new :class:`!Path` instance.
16171617

16181618

1619+
.. method:: Path.move(target)
1620+
1621+
Move this file or directory tree to the given *target*, and return a new
1622+
:class:`!Path` instance pointing to *target*.
1623+
1624+
If the *target* doesn't exist it will be created. If both this path and the
1625+
*target* are existing files, then the target is overwritten. If both paths
1626+
point to the same file or directory, or the *target* is a non-empty
1627+
directory, then :exc:`OSError` is raised.
1628+
1629+
If both paths are on the same filesystem, the move is performed with
1630+
:func:`os.replace`. Otherwise, this path is copied (preserving metadata and
1631+
symlinks) and then deleted.
1632+
1633+
.. versionadded:: 3.14
1634+
1635+
16191636
.. method:: Path.unlink(missing_ok=False)
16201637

16211638
Remove this file or symbolic link. If the path points to a directory,

Doc/whatsnew/3.14.rst

+4-1
Original file line numberDiff line numberDiff line change
@@ -185,10 +185,13 @@ os
185185
pathlib
186186
-------
187187

188-
* Add methods to :class:`pathlib.Path` to recursively copy or remove files:
188+
* Add methods to :class:`pathlib.Path` to recursively copy, move, or remove
189+
files and directories:
189190

190191
* :meth:`~pathlib.Path.copy` copies a file or directory tree to a given
191192
destination.
193+
* :meth:`~pathlib.Path.move` moves a file or directory tree to a given
194+
destination.
192195
* :meth:`~pathlib.Path.delete` removes a file or directory tree.
193196

194197
(Contributed by Barney Gale in :gh:`73991`.)

Lib/pathlib/_abc.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import functools
1515
import operator
1616
import posixpath
17-
from errno import EINVAL
17+
from errno import EINVAL, EXDEV
1818
from glob import _GlobberBase, _no_recurse_symlinks
1919
from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO
2020
from pathlib._os import copyfileobj
@@ -928,6 +928,25 @@ def replace(self, target):
928928
"""
929929
raise UnsupportedOperation(self._unsupported_msg('replace()'))
930930

931+
def move(self, target):
932+
"""
933+
Recursively move this file or directory tree to the given destination.
934+
"""
935+
self._ensure_different_file(target)
936+
try:
937+
return self.replace(target)
938+
except UnsupportedOperation:
939+
pass
940+
except TypeError:
941+
if not isinstance(target, PathBase):
942+
raise
943+
except OSError as err:
944+
if err.errno != EXDEV:
945+
raise
946+
target = self.copy(target, follow_symlinks=False, preserve_metadata=True)
947+
self.delete()
948+
return target
949+
931950
def chmod(self, mode, *, follow_symlinks=True):
932951
"""
933952
Change the permissions of the path, like os.chmod().

Lib/test/test_pathlib/test_pathlib.py

+62
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@
4545
{os.open, os.stat, os.unlink, os.rmdir} <= os.supports_dir_fd and
4646
os.listdir in os.supports_fd and os.stat in os.supports_follow_symlinks)
4747

48+
def patch_replace(old_test):
49+
def new_replace(self, target):
50+
raise OSError(errno.EXDEV, "Cross-device link", self, target)
51+
52+
def new_test(self):
53+
old_replace = self.cls.replace
54+
self.cls.replace = new_replace
55+
try:
56+
old_test(self)
57+
finally:
58+
self.cls.replace = old_replace
59+
return new_test
60+
4861
#
4962
# Tests for the pure classes.
5063
#
@@ -799,6 +812,55 @@ def test_copy_dir_preserve_metadata_xattrs(self):
799812
target_file = target.joinpath('dirD', 'fileD')
800813
self.assertEqual(os.getxattr(target_file, b'user.foo'), b'42')
801814

815+
@patch_replace
816+
def test_move_file_other_fs(self):
817+
self.test_move_file()
818+
819+
@patch_replace
820+
def test_move_file_to_file_other_fs(self):
821+
self.test_move_file_to_file()
822+
823+
@patch_replace
824+
def test_move_file_to_dir_other_fs(self):
825+
self.test_move_file_to_dir()
826+
827+
@patch_replace
828+
def test_move_dir_other_fs(self):
829+
self.test_move_dir()
830+
831+
@patch_replace
832+
def test_move_dir_to_dir_other_fs(self):
833+
self.test_move_dir_to_dir()
834+
835+
@patch_replace
836+
def test_move_dir_into_itself_other_fs(self):
837+
self.test_move_dir_into_itself()
838+
839+
@patch_replace
840+
@needs_symlinks
841+
def test_move_file_symlink_other_fs(self):
842+
self.test_move_file_symlink()
843+
844+
@patch_replace
845+
@needs_symlinks
846+
def test_move_file_symlink_to_itself_other_fs(self):
847+
self.test_move_file_symlink_to_itself()
848+
849+
@patch_replace
850+
@needs_symlinks
851+
def test_move_dir_symlink_other_fs(self):
852+
self.test_move_dir_symlink()
853+
854+
@patch_replace
855+
@needs_symlinks
856+
def test_move_dir_symlink_to_itself_other_fs(self):
857+
self.test_move_dir_symlink_to_itself()
858+
859+
@patch_replace
860+
@needs_symlinks
861+
def test_move_dangling_symlink_other_fs(self):
862+
self.test_move_dangling_symlink()
863+
802864
def test_resolve_nonexist_relative_issue38671(self):
803865
p = self.cls('non', 'exist')
804866

Lib/test/test_pathlib/test_pathlib_abc.py

+119
Original file line numberDiff line numberDiff line change
@@ -2072,6 +2072,125 @@ def test_copy_dangling_symlink(self):
20722072
self.assertTrue(target2.joinpath('link').is_symlink())
20732073
self.assertEqual(target2.joinpath('link').readlink(), self.cls('nonexistent'))
20742074

2075+
def test_move_file(self):
2076+
base = self.cls(self.base)
2077+
source = base / 'fileA'
2078+
source_text = source.read_text()
2079+
target = base / 'fileA_moved'
2080+
result = source.move(target)
2081+
self.assertEqual(result, target)
2082+
self.assertFalse(source.exists())
2083+
self.assertTrue(target.exists())
2084+
self.assertEqual(source_text, target.read_text())
2085+
2086+
def test_move_file_to_file(self):
2087+
base = self.cls(self.base)
2088+
source = base / 'fileA'
2089+
source_text = source.read_text()
2090+
target = base / 'dirB' / 'fileB'
2091+
result = source.move(target)
2092+
self.assertEqual(result, target)
2093+
self.assertFalse(source.exists())
2094+
self.assertTrue(target.exists())
2095+
self.assertEqual(source_text, target.read_text())
2096+
2097+
def test_move_file_to_dir(self):
2098+
base = self.cls(self.base)
2099+
source = base / 'fileA'
2100+
target = base / 'dirB'
2101+
self.assertRaises(OSError, source.move, target)
2102+
2103+
def test_move_file_to_itself(self):
2104+
base = self.cls(self.base)
2105+
source = base / 'fileA'
2106+
self.assertRaises(OSError, source.move, source)
2107+
2108+
def test_move_dir(self):
2109+
base = self.cls(self.base)
2110+
source = base / 'dirC'
2111+
target = base / 'dirC_moved'
2112+
result = source.move(target)
2113+
self.assertEqual(result, target)
2114+
self.assertFalse(source.exists())
2115+
self.assertTrue(target.is_dir())
2116+
self.assertTrue(target.joinpath('dirD').is_dir())
2117+
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
2118+
self.assertEqual(target.joinpath('dirD', 'fileD').read_text(),
2119+
"this is file D\n")
2120+
self.assertTrue(target.joinpath('fileC').is_file())
2121+
self.assertTrue(target.joinpath('fileC').read_text(),
2122+
"this is file C\n")
2123+
2124+
def test_move_dir_to_dir(self):
2125+
base = self.cls(self.base)
2126+
source = base / 'dirC'
2127+
target = base / 'dirB'
2128+
self.assertRaises(OSError, source.move, target)
2129+
self.assertTrue(source.exists())
2130+
self.assertTrue(target.exists())
2131+
2132+
def test_move_dir_to_itself(self):
2133+
base = self.cls(self.base)
2134+
source = base / 'dirC'
2135+
self.assertRaises(OSError, source.move, source)
2136+
self.assertTrue(source.exists())
2137+
2138+
def test_move_dir_into_itself(self):
2139+
base = self.cls(self.base)
2140+
source = base / 'dirC'
2141+
target = base / 'dirC' / 'bar'
2142+
self.assertRaises(OSError, source.move, target)
2143+
self.assertTrue(source.exists())
2144+
self.assertFalse(target.exists())
2145+
2146+
@needs_symlinks
2147+
def test_move_file_symlink(self):
2148+
base = self.cls(self.base)
2149+
source = base / 'linkA'
2150+
source_readlink = source.readlink()
2151+
target = base / 'linkA_moved'
2152+
result = source.move(target)
2153+
self.assertEqual(result, target)
2154+
self.assertFalse(source.exists())
2155+
self.assertTrue(target.is_symlink())
2156+
self.assertEqual(source_readlink, target.readlink())
2157+
2158+
@needs_symlinks
2159+
def test_move_file_symlink_to_itself(self):
2160+
base = self.cls(self.base)
2161+
source = base / 'linkA'
2162+
self.assertRaises(OSError, source.move, source)
2163+
2164+
@needs_symlinks
2165+
def test_move_dir_symlink(self):
2166+
base = self.cls(self.base)
2167+
source = base / 'linkB'
2168+
source_readlink = source.readlink()
2169+
target = base / 'linkB_moved'
2170+
result = source.move(target)
2171+
self.assertEqual(result, target)
2172+
self.assertFalse(source.exists())
2173+
self.assertTrue(target.is_symlink())
2174+
self.assertEqual(source_readlink, target.readlink())
2175+
2176+
@needs_symlinks
2177+
def test_move_dir_symlink_to_itself(self):
2178+
base = self.cls(self.base)
2179+
source = base / 'linkB'
2180+
self.assertRaises(OSError, source.move, source)
2181+
2182+
@needs_symlinks
2183+
def test_move_dangling_symlink(self):
2184+
base = self.cls(self.base)
2185+
source = base / 'brokenLink'
2186+
source_readlink = source.readlink()
2187+
target = base / 'brokenLink_moved'
2188+
result = source.move(target)
2189+
self.assertEqual(result, target)
2190+
self.assertFalse(source.exists())
2191+
self.assertTrue(target.is_symlink())
2192+
self.assertEqual(source_readlink, target.readlink())
2193+
20752194
def test_iterdir(self):
20762195
P = self.cls
20772196
p = P(self.base)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add :meth:`pathlib.Path.move`, which moves a file or directory tree.

0 commit comments

Comments
 (0)