Skip to content

GH-73991: Add pathlib.Path.move that can handle rename across FS #30650

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion Doc/library/pathlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,7 @@ call fails (for example because the path doesn't exist).

The children are yielded in arbitrary order, and the special entries
``'.'`` and ``'..'`` are not included. If a file is removed from or added
to the directory after creating the iterator, whether an path object for
to the directory after creating the iterator, whether a path object for
that file be included is unspecified.

.. method:: Path.lchmod(mode)
Expand Down Expand Up @@ -1034,6 +1034,10 @@ call fails (for example because the path doesn't exist).
relative to the current working directory, *not* the directory of the Path
object.

.. note::
This method can't move files from one filesystem to another.
Use :meth:`Path.move` for such cases.

.. versionchanged:: 3.8
Added return value, return the new Path instance.

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

.. note::
This method can't move files from one filesystem to another.
Use :meth:`Path.move` for such cases.

.. versionchanged:: 3.8
Added return value, return the new Path instance.


.. method:: Path.move(target, copy_function=shutil.copy2)

Rename this file or directory to the given *target*, and return a new Path
instance pointing to *target*. If *target* points to an existing file,
it will be unconditionally replaced. If *target* points to a directory,
then *src* is moved inside that directory.

The target path may be absolute or relative. Relative paths are interpreted
relative to the current working directory, *not* the directory of the Path
object.

This method uses :func:`shutil.move` to execute the renaming, and can receive
an optional `copy_function`. In none in given :func:`shutil.copy2` will be used.

.. note::
:func:`shutil.copy2` will try and preserve the metadata. In case that, copying
the metadata is not possible, you can use func:`shutil.copy` as the *copy_function*.
This allows the move to succeed when it is not possible to also copy the metadata,
at the expense of not copying any of the metadata.


.. method:: Path.absolute()

Make the path absolute, without normalization or resolving symlinks.
Expand Down
11 changes: 11 additions & 0 deletions Lib/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import posixpath
import re
import shutil
import sys
import warnings
from _collections_abc import Sequence
Expand Down Expand Up @@ -1177,6 +1178,16 @@ def replace(self, target):
os.replace(self, target)
return self.__class__(target)

def move(self, target, copy_function=shutil.copy2):
"""
Recursively move a file or directory to another location (target),
using ``shutil.move``.
If *target* is on the current filesystem, then ``os.rename()`` is used.
Otherwise, *target* will be copied using *copy_function* and then removed.
Returns the new Path instance pointing to the target path.
"""
return self.__class__(shutil.move(self, target, copy_function))

def symlink_to(self, target, target_is_directory=False):
"""
Make this path a symlink pointing to the target path.
Expand Down
42 changes: 42 additions & 0 deletions Lib/test/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -2046,6 +2046,48 @@ def test_replace(self):
self.assertEqual(os.stat(r).st_size, size)
self.assertFileNotFound(q.stat)

def test_move(self):
P = self.cls(BASE)
p = P / 'fileA'
size = p.stat().st_size
# Replacing a non-existing path.
q = P / 'dirA' / 'fileAA'
replaced_p = p.move(q)
self.assertEqual(replaced_p, q)
self.assertEqual(q.stat().st_size, size)
self.assertFileNotFound(p.stat)
# Replacing another (existing) path.
r = rel_join('dirB', 'fileB')
replaced_q = q.move(r)
self.assertEqual(replaced_q, self.cls(r))
self.assertEqual(os.stat(r).st_size, size)
self.assertFileNotFound(q.stat)

# test moving to existing directory
newdir = P / 'newDir/'
newdir.mkdir()

replaced_q.move(newdir)
self.assertTrue(newdir.joinpath(replaced_q.stem).exists())

def test_move_is_calling_os_rename(self):
P = self.cls(BASE)
src = P / 'fileA'
dst = src / 'dirA'
with mock.patch("os.rename") as rename:
src.move(dst)
self.assertTrue(rename.called)
rename.assert_called()
rename.assert_called_with(src.joinpath(), dst.joinpath())

@os_helper.skip_unless_symlink
def test_move_symlink(self):
P = self.cls(BASE)
link = P / 'linkA'
link.move( P / 'newLink')
newlink = P / 'newLink'
self.assertTrue(newlink.is_symlink())

@os_helper.skip_unless_symlink
def test_readlink(self):
P = self.cls(BASE)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Add ``Pathlib.move`` that can handle rename across FS
With this change, ``Pathlib.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.