Skip to content

Commit c68a93c

Browse files
authored
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.
1 parent dbc1752 commit c68a93c

File tree

6 files changed

+96
-4
lines changed

6 files changed

+96
-4
lines changed

Doc/library/pathlib.rst

+21
Original file line numberDiff line numberDiff line change
@@ -1575,6 +1575,18 @@ Copying, moving and deleting
15751575
.. versionadded:: 3.14
15761576

15771577

1578+
.. method:: Path.copy_into(target_dir, *, follow_symlinks=True, \
1579+
dirs_exist_ok=False, preserve_metadata=False, \
1580+
ignore=None, on_error=None)
1581+
1582+
Copy this file or directory tree into the given *target_dir*, which should
1583+
be an existing directory. Other arguments are handled identically to
1584+
:meth:`Path.copy`. Returns a new :class:`!Path` instance pointing to the
1585+
copy.
1586+
1587+
.. versionadded:: 3.14
1588+
1589+
15781590
.. method:: Path.rename(target)
15791591

15801592
Rename this file or directory to the given *target*, and return a new
@@ -1633,6 +1645,15 @@ Copying, moving and deleting
16331645
.. versionadded:: 3.14
16341646

16351647

1648+
.. method:: Path.move_into(target_dir)
1649+
1650+
Move this file or directory tree into the given *target_dir*, which should
1651+
be an existing directory. Returns a new :class:`!Path` instance pointing to
1652+
the moved path.
1653+
1654+
.. versionadded:: 3.14
1655+
1656+
16361657
.. method:: Path.unlink(missing_ok=False)
16371658

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

Doc/whatsnew/3.14.rst

+4-4
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,10 @@ pathlib
188188
* Add methods to :class:`pathlib.Path` to recursively copy, move, or remove
189189
files and directories:
190190

191-
* :meth:`~pathlib.Path.copy` copies a file or directory tree to a given
192-
destination.
193-
* :meth:`~pathlib.Path.move` moves a file or directory tree to a given
194-
destination.
191+
* :meth:`~pathlib.Path.copy` copies a file or directory tree to a destination.
192+
* :meth:`~pathlib.Path.copy_into` copies *into* a destination directory.
193+
* :meth:`~pathlib.Path.move` moves a file or directory tree to a destination.
194+
* :meth:`~pathlib.Path.move_into` moves *into* a destination directory.
195195
* :meth:`~pathlib.Path.delete` removes a file or directory tree.
196196

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

Lib/pathlib/_abc.py

+31
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,24 @@ def copy(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
904904
on_error(err)
905905
return target
906906

907+
def copy_into(self, target_dir, *, follow_symlinks=True,
908+
dirs_exist_ok=False, preserve_metadata=False, ignore=None,
909+
on_error=None):
910+
"""
911+
Copy this file or directory tree into the given existing directory.
912+
"""
913+
name = self.name
914+
if not name:
915+
raise ValueError(f"{self!r} has an empty name")
916+
elif isinstance(target_dir, PathBase):
917+
target = target_dir / name
918+
else:
919+
target = self.with_segments(target_dir, name)
920+
return self.copy(target, follow_symlinks=follow_symlinks,
921+
dirs_exist_ok=dirs_exist_ok,
922+
preserve_metadata=preserve_metadata, ignore=ignore,
923+
on_error=on_error)
924+
907925
def rename(self, target):
908926
"""
909927
Rename this path to the target path.
@@ -947,6 +965,19 @@ def move(self, target):
947965
self.delete()
948966
return target
949967

968+
def move_into(self, target_dir):
969+
"""
970+
Move this file or directory tree into the given existing directory.
971+
"""
972+
name = self.name
973+
if not name:
974+
raise ValueError(f"{self!r} has an empty name")
975+
elif isinstance(target_dir, PathBase):
976+
target = target_dir / name
977+
else:
978+
target = self.with_segments(target_dir, name)
979+
return self.move(target)
980+
950981
def chmod(self, mode, *, follow_symlinks=True):
951982
"""
952983
Change the permissions of the path, like os.chmod().

Lib/test/test_pathlib/test_pathlib.py

+8
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,14 @@ def test_move_dir_symlink_to_itself_other_fs(self):
861861
def test_move_dangling_symlink_other_fs(self):
862862
self.test_move_dangling_symlink()
863863

864+
@patch_replace
865+
def test_move_into_other_os(self):
866+
self.test_move_into()
867+
868+
@patch_replace
869+
def test_move_into_empty_name_other_os(self):
870+
self.test_move_into_empty_name()
871+
864872
def test_resolve_nonexist_relative_issue38671(self):
865873
p = self.cls('non', 'exist')
866874

Lib/test/test_pathlib/test_pathlib_abc.py

+30
Original file line numberDiff line numberDiff line change
@@ -2072,6 +2072,20 @@ 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_copy_into(self):
2076+
base = self.cls(self.base)
2077+
source = base / 'fileA'
2078+
target_dir = base / 'dirA'
2079+
result = source.copy_into(target_dir)
2080+
self.assertEqual(result, target_dir / 'fileA')
2081+
self.assertTrue(result.exists())
2082+
self.assertEqual(source.read_text(), result.read_text())
2083+
2084+
def test_copy_into_empty_name(self):
2085+
source = self.cls('')
2086+
target_dir = self.base
2087+
self.assertRaises(ValueError, source.copy_into, target_dir)
2088+
20752089
def test_move_file(self):
20762090
base = self.cls(self.base)
20772091
source = base / 'fileA'
@@ -2191,6 +2205,22 @@ def test_move_dangling_symlink(self):
21912205
self.assertTrue(target.is_symlink())
21922206
self.assertEqual(source_readlink, target.readlink())
21932207

2208+
def test_move_into(self):
2209+
base = self.cls(self.base)
2210+
source = base / 'fileA'
2211+
source_text = source.read_text()
2212+
target_dir = base / 'dirA'
2213+
result = source.move_into(target_dir)
2214+
self.assertEqual(result, target_dir / 'fileA')
2215+
self.assertFalse(source.exists())
2216+
self.assertTrue(result.exists())
2217+
self.assertEqual(source_text, result.read_text())
2218+
2219+
def test_move_into_empty_name(self):
2220+
source = self.cls('')
2221+
target_dir = self.base
2222+
self.assertRaises(ValueError, source.move_into, target_dir)
2223+
21942224
def test_iterdir(self):
21952225
P = self.cls
21962226
p = P(self.base)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add :meth:`pathlib.Path.copy_into` and :meth:`~pathlib.Path.move_into`,
2+
which copy and move files and directories into *existing* directories.

0 commit comments

Comments
 (0)