Skip to content

Commit 8be5cdc

Browse files
committed
bpo-46317: Add pathlib.Path.move that can handle renaming across FS
With this change, ``pathlib.Path.move`` adds the ability to handle renaming across file system and also preserve metadata when renaming, since ``shutil.move`` using ``shutil.copy2`` is used under the hood.
1 parent 832876b commit 8be5cdc

File tree

4 files changed

+87
-0
lines changed

4 files changed

+87
-0
lines changed

Doc/library/pathlib.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,10 @@ call fails (for example because the path doesn't exist).
10341034
relative to the current working directory, *not* the directory of the Path
10351035
object.
10361036

1037+
.. note::
1038+
This method can't move files from one filesystem to another.
1039+
Use :meth:`Path.move` for such cases.
1040+
10371041
.. versionchanged:: 3.8
10381042
Added return value, return the new Path instance.
10391043

@@ -1048,10 +1052,35 @@ call fails (for example because the path doesn't exist).
10481052
relative to the current working directory, *not* the directory of the Path
10491053
object.
10501054

1055+
.. note::
1056+
This method can't move files from one filesystem to another.
1057+
Use :meth:`Path.move` for such cases.
1058+
10511059
.. versionchanged:: 3.8
10521060
Added return value, return the new Path instance.
10531061

10541062

1063+
.. method:: Path.move(target, copy_function=shutil.copy2)
1064+
1065+
Rename this file or directory to the given *target*, and return a new Path
1066+
instance pointing to *target*. If *target* points to an existing file,
1067+
it will be unconditionally replaced. If *target* points to a directory,
1068+
then *src* is moved inside that directory.
1069+
1070+
The target path may be absolute or relative. Relative paths are interpreted
1071+
relative to the current working directory, *not* the directory of the Path
1072+
object.
1073+
1074+
This method uses :func:`shutil.move` to execute the renaming, and can receive
1075+
an optional `copy_function`. In none in given :func:`shutil.copy2` will be used.
1076+
1077+
.. note::
1078+
:func:`shutil.copy2` will try and preserve the metadata. In case that, copying
1079+
the metadata is not possible, you can use func:`shutil.copy` as the *copy_function*.
1080+
This allows the move to succeed when it is not possible to also copy the metadata,
1081+
at the expense of not copying any of the metadata.
1082+
1083+
10551084
.. method:: Path.absolute()
10561085

10571086
Make the path absolute, without normalization or resolving symlinks.

Lib/pathlib.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import posixpath
77
import re
8+
import shutil
89
import sys
910
import warnings
1011
from _collections_abc import Sequence
@@ -1177,6 +1178,16 @@ def replace(self, target):
11771178
os.replace(self, target)
11781179
return self.__class__(target)
11791180

1181+
def move(self, target, copy_function=shutil.copy2):
1182+
"""
1183+
Recursively move a file or directory to another location (target),
1184+
using ``shutil.move``.
1185+
If *target* is on the current filesystem, then ``os.rename()`` is used.
1186+
Otherwise, *target* will be copied using *copy_function* and then removed.
1187+
Returns the new Path instance pointing to the target path.
1188+
"""
1189+
return self.__class__(shutil.move(self, target, copy_function))
1190+
11801191
def symlink_to(self, target, target_is_directory=False):
11811192
"""
11821193
Make this path a symlink pointing to the target path.

Lib/test/test_pathlib.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,6 +2046,48 @@ def test_replace(self):
20462046
self.assertEqual(os.stat(r).st_size, size)
20472047
self.assertFileNotFound(q.stat)
20482048

2049+
def test_move(self):
2050+
P = self.cls(BASE)
2051+
p = P / 'fileA'
2052+
size = p.stat().st_size
2053+
# Replacing a non-existing path.
2054+
q = P / 'dirA' / 'fileAA'
2055+
replaced_p = p.move(q)
2056+
self.assertEqual(replaced_p, q)
2057+
self.assertEqual(q.stat().st_size, size)
2058+
self.assertFileNotFound(p.stat)
2059+
# Replacing another (existing) path.
2060+
r = rel_join('dirB', 'fileB')
2061+
replaced_q = q.move(r)
2062+
self.assertEqual(replaced_q, self.cls(r))
2063+
self.assertEqual(os.stat(r).st_size, size)
2064+
self.assertFileNotFound(q.stat)
2065+
2066+
# test moving to existing directory
2067+
newdir = P / 'newDir/'
2068+
newdir.mkdir()
2069+
2070+
replaced_q.move(newdir)
2071+
self.assertTrue(newdir.joinpath(replaced_q.stem).exists())
2072+
2073+
def test_move_is_calling_os_rename(self):
2074+
P = self.cls(BASE)
2075+
src = P / 'fileA'
2076+
dst = src / 'dirA'
2077+
with mock.patch("os.rename") as rename:
2078+
src.move(dst)
2079+
self.assertTrue(rename.called)
2080+
rename.assert_called()
2081+
rename.assert_called_with(src.joinpath(), dst.joinpath())
2082+
2083+
@os_helper.skip_unless_symlink
2084+
def test_move_symlink(self):
2085+
P = self.cls(BASE)
2086+
link = P / 'linkA'
2087+
link.move( P / 'newLink')
2088+
newlink = P / 'newLink'
2089+
self.assertTrue(newlink.is_symlink())
2090+
20492091
@os_helper.skip_unless_symlink
20502092
def test_readlink(self):
20512093
P = self.cls(BASE)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add ``Pathlib.move`` that can handle rename across FS
2+
With this change, ``Pathlib.move`` adds the ability
3+
to handle renaming across file system and also preserve metadata
4+
when renaming, since ``shutil.move`` using ``shutil.copy2`` is used
5+
under the hood.

0 commit comments

Comments
 (0)