From c68a93c582cc733c399a1cf9e850e5071f79aec1 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Mon, 26 Aug 2024 14:14:23 +0100 Subject: GH-73991: Add `pathlib.Path.copy_into()` and `move_into()` (#123314) These two methods accept an *existing* directory path, onto which we join the source path's base name to form the final target path. A possible alternative implementation is to check for directories in `copy()` and `move()` and adjust the target path, which is done in several `shutil` functions. This behaviour is helpful in a shell context, but less so in a stored program that explicitly specifies destinations. For example, a user that calls `Path('foo.py').copy('bar.py')` might not imagine that `bar.py/foo.py` would be created, but under the alternative implementation this will happen if `bar.py` is an existing directory. --- Doc/library/pathlib.rst | 21 +++++++++++++++ Doc/whatsnew/3.14.rst | 8 +++--- Lib/pathlib/_abc.py | 31 ++++++++++++++++++++++ Lib/test/test_pathlib/test_pathlib.py | 8 ++++++ Lib/test/test_pathlib/test_pathlib_abc.py | 30 +++++++++++++++++++++ .../2024-08-25-16-59-20.gh-issue-73991.1w8u3K.rst | 2 ++ 6 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-08-25-16-59-20.gh-issue-73991.1w8u3K.rst diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 9f5f10a..0b6a6a3 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -1575,6 +1575,18 @@ Copying, moving and deleting .. versionadded:: 3.14 +.. method:: Path.copy_into(target_dir, *, follow_symlinks=True, \ + dirs_exist_ok=False, preserve_metadata=False, \ + ignore=None, on_error=None) + + Copy this file or directory tree into the given *target_dir*, which should + be an existing directory. Other arguments are handled identically to + :meth:`Path.copy`. Returns a new :class:`!Path` instance pointing to the + copy. + + .. versionadded:: 3.14 + + .. method:: Path.rename(target) Rename this file or directory to the given *target*, and return a new @@ -1633,6 +1645,15 @@ Copying, moving and deleting .. versionadded:: 3.14 +.. method:: Path.move_into(target_dir) + + Move this file or directory tree into the given *target_dir*, which should + be an existing directory. Returns a new :class:`!Path` instance pointing to + the moved path. + + .. 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 e5c0fda..34434e4 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -188,10 +188,10 @@ pathlib * 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.copy` copies a file or directory tree to a destination. + * :meth:`~pathlib.Path.copy_into` copies *into* a destination directory. + * :meth:`~pathlib.Path.move` moves a file or directory tree to a destination. + * :meth:`~pathlib.Path.move_into` moves *into* a destination directory. * :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 93758b1..0c76480 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -904,6 +904,24 @@ class PathBase(PurePathBase): on_error(err) return target + def copy_into(self, target_dir, *, follow_symlinks=True, + dirs_exist_ok=False, preserve_metadata=False, ignore=None, + on_error=None): + """ + Copy this file or directory tree into the given existing directory. + """ + name = self.name + if not name: + raise ValueError(f"{self!r} has an empty name") + elif isinstance(target_dir, PathBase): + target = target_dir / name + else: + target = self.with_segments(target_dir, name) + return self.copy(target, follow_symlinks=follow_symlinks, + dirs_exist_ok=dirs_exist_ok, + preserve_metadata=preserve_metadata, ignore=ignore, + on_error=on_error) + def rename(self, target): """ Rename this path to the target path. @@ -947,6 +965,19 @@ class PathBase(PurePathBase): self.delete() return target + def move_into(self, target_dir): + """ + Move this file or directory tree into the given existing directory. + """ + name = self.name + if not name: + raise ValueError(f"{self!r} has an empty name") + elif isinstance(target_dir, PathBase): + target = target_dir / name + else: + target = self.with_segments(target_dir, name) + return self.move(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 4d38246..080b8df 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -861,6 +861,14 @@ class PathTest(test_pathlib_abc.DummyPathTest, PurePathTest): def test_move_dangling_symlink_other_fs(self): self.test_move_dangling_symlink() + @patch_replace + def test_move_into_other_os(self): + self.test_move_into() + + @patch_replace + def test_move_into_empty_name_other_os(self): + self.test_move_into_empty_name() + 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 7f8f614..4a32cb9 100644 --- a/Lib/test/test_pathlib/test_pathlib_abc.py +++ b/Lib/test/test_pathlib/test_pathlib_abc.py @@ -2072,6 +2072,20 @@ class DummyPathTest(DummyPurePathTest): self.assertTrue(target2.joinpath('link').is_symlink()) self.assertEqual(target2.joinpath('link').readlink(), self.cls('nonexistent')) + def test_copy_into(self): + base = self.cls(self.base) + source = base / 'fileA' + target_dir = base / 'dirA' + result = source.copy_into(target_dir) + self.assertEqual(result, target_dir / 'fileA') + self.assertTrue(result.exists()) + self.assertEqual(source.read_text(), result.read_text()) + + def test_copy_into_empty_name(self): + source = self.cls('') + target_dir = self.base + self.assertRaises(ValueError, source.copy_into, target_dir) + def test_move_file(self): base = self.cls(self.base) source = base / 'fileA' @@ -2191,6 +2205,22 @@ class DummyPathTest(DummyPurePathTest): self.assertTrue(target.is_symlink()) self.assertEqual(source_readlink, target.readlink()) + def test_move_into(self): + base = self.cls(self.base) + source = base / 'fileA' + source_text = source.read_text() + target_dir = base / 'dirA' + result = source.move_into(target_dir) + self.assertEqual(result, target_dir / 'fileA') + self.assertFalse(source.exists()) + self.assertTrue(result.exists()) + self.assertEqual(source_text, result.read_text()) + + def test_move_into_empty_name(self): + source = self.cls('') + target_dir = self.base + self.assertRaises(ValueError, source.move_into, target_dir) + def test_iterdir(self): P = self.cls p = P(self.base) diff --git a/Misc/NEWS.d/next/Library/2024-08-25-16-59-20.gh-issue-73991.1w8u3K.rst b/Misc/NEWS.d/next/Library/2024-08-25-16-59-20.gh-issue-73991.1w8u3K.rst new file mode 100644 index 0000000..4ad5a06 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-08-25-16-59-20.gh-issue-73991.1w8u3K.rst @@ -0,0 +1,2 @@ +Add :meth:`pathlib.Path.copy_into` and :meth:`~pathlib.Path.move_into`, +which copy and move files and directories into *existing* directories. -- cgit v0.12