From 8be5cdc1036b74c6bbdfcc2133e0dd0faceb08da Mon Sep 17 00:00:00 2001 From: Oz N Tiram Date: Sat, 5 Feb 2022 20:25:27 +0100 Subject: [PATCH 1/2] 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. --- Doc/library/pathlib.rst | 29 +++++++++++++ Lib/pathlib.py | 11 +++++ Lib/test/test_pathlib.py | 42 +++++++++++++++++++ .../2022-01-17-19-43-43.bpo-46317.WI2dj4.rst | 5 +++ 4 files changed, 87 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2022-01-17-19-43-43.bpo-46317.WI2dj4.rst diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 7ab603fd133b86..87f529c24f0c24 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -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. @@ -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. diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 7f4210e2b80c9b..43209667ecc553 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -5,6 +5,7 @@ import os import posixpath import re +import shutil import sys import warnings from _collections_abc import Sequence @@ -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. diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index ec2baca18fd817..9db3b88b5b09b4 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -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) diff --git a/Misc/NEWS.d/next/Library/2022-01-17-19-43-43.bpo-46317.WI2dj4.rst b/Misc/NEWS.d/next/Library/2022-01-17-19-43-43.bpo-46317.WI2dj4.rst new file mode 100644 index 00000000000000..3260f9a2cd31f6 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2022-01-17-19-43-43.bpo-46317.WI2dj4.rst @@ -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. From edce4184eb872d6b929a8f790abb49fd9c91550e Mon Sep 17 00:00:00 2001 From: Oz N Tiram Date: Sat, 5 Feb 2022 20:27:52 +0100 Subject: [PATCH 2/2] Fix grammar: replace wrong an with a --- Doc/library/pathlib.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 87f529c24f0c24..7621a90f6c5a9b 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -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)