Skip to content

Commit

Permalink
pythonGH-73991: Add pathlib.Path.copy_into() and move_into() (pyt…
Browse files Browse the repository at this point in the history
…hon#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.
  • Loading branch information
barneygale authored Aug 26, 2024
1 parent dbc1752 commit c68a93c
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 4 deletions.
21 changes: 21 additions & 0 deletions Doc/library/pathlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.)
Expand Down
31 changes: 31 additions & 0 deletions Lib/pathlib/_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,24 @@ def copy(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
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.
Expand Down Expand Up @@ -947,6 +965,19 @@ def move(self, target):
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().
Expand Down
8 changes: 8 additions & 0 deletions Lib/test/test_pathlib/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,14 @@ def test_move_dir_symlink_to_itself_other_fs(self):
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')

Expand Down
30 changes: 30 additions & 0 deletions Lib/test/test_pathlib/test_pathlib_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2072,6 +2072,20 @@ def test_copy_dangling_symlink(self):
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'
Expand Down Expand Up @@ -2191,6 +2205,22 @@ def test_move_dangling_symlink(self):
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.

0 comments on commit c68a93c

Please sign in to comment.