Skip to content

Commit 35e998f

Browse files
GH-73991: Add pathlib.Path.copytree() (#120718)
Add `pathlib.Path.copytree()` method, which recursively copies one directory to another. This differs from `shutil.copytree()` in the following respects: 1. Our method has a *follow_symlinks* argument, whereas shutil's has a *symlinks* argument with an inverted meaning. 2. Our method lacks something like a *copy_function* argument. It always uses `Path.copy()` to copy files. 3. Our method lacks something like a *ignore_dangling_symlinks* argument. Instead, users can filter out danging symlinks with *ignore*, or ignore exceptions with *on_error* 4. Our *ignore* argument is a callable that accepts a single path object, whereas shutil's accepts a path and a list of child filenames. 5. We add an *on_error* argument, which is a callable that accepts an `OSError` instance. (`Path.walk()` also accepts such a callable). Co-authored-by: Nice Zombies <[email protected]>
1 parent bc37ac7 commit 35e998f

File tree

6 files changed

+231
-0
lines changed

6 files changed

+231
-0
lines changed

Doc/library/pathlib.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,6 +1455,33 @@ Copying, renaming and deleting
14551455
.. versionadded:: 3.14
14561456

14571457

1458+
.. method:: Path.copytree(target, *, follow_symlinks=True, dirs_exist_ok=False, \
1459+
ignore=None, on_error=None)
1460+
1461+
Recursively copy this directory tree to the given destination.
1462+
1463+
If a symlink is encountered in the source tree, and *follow_symlinks* is
1464+
true (the default), the symlink's target is copied. Otherwise, the symlink
1465+
is recreated in the destination tree.
1466+
1467+
If the destination is an existing directory and *dirs_exist_ok* is false
1468+
(the default), a :exc:`FileExistsError` is raised. Otherwise, the copying
1469+
operation will continue if it encounters existing directories, and files
1470+
within the destination tree will be overwritten by corresponding files from
1471+
the source tree.
1472+
1473+
If *ignore* is given, it should be a callable accepting one argument: a
1474+
file or directory path within the source tree. The callable may return true
1475+
to suppress copying of the path.
1476+
1477+
If *on_error* is given, it should be a callable accepting one argument: an
1478+
instance of :exc:`OSError`. The callable may re-raise the exception or do
1479+
nothing, in which case the copying operation continues. If *on_error* isn't
1480+
given, exceptions are propagated to the caller.
1481+
1482+
.. versionadded:: 3.14
1483+
1484+
14581485
.. method:: Path.rename(target)
14591486

14601487
Rename this file or directory to the given *target*, and return a new

Doc/whatsnew/3.14.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ pathlib
106106
* Add :meth:`pathlib.Path.copy`, which copies the content of one file to
107107
another, like :func:`shutil.copyfile`.
108108
(Contributed by Barney Gale in :gh:`73991`.)
109+
* Add :meth:`pathlib.Path.copytree`, which copies one directory tree to
110+
another.
111+
(Contributed by Barney Gale in :gh:`73991`.)
109112

110113
symtable
111114
--------

Lib/pathlib/_abc.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,36 @@ def copy(self, target, follow_symlinks=True):
815815
else:
816816
raise
817817

818+
def copytree(self, target, *, follow_symlinks=True, dirs_exist_ok=False,
819+
ignore=None, on_error=None):
820+
"""
821+
Recursively copy this directory tree to the given destination.
822+
"""
823+
if not isinstance(target, PathBase):
824+
target = self.with_segments(target)
825+
if on_error is None:
826+
def on_error(err):
827+
raise err
828+
stack = [(self, target)]
829+
while stack:
830+
source_dir, target_dir = stack.pop()
831+
try:
832+
sources = source_dir.iterdir()
833+
target_dir.mkdir(exist_ok=dirs_exist_ok)
834+
for source in sources:
835+
if ignore and ignore(source):
836+
continue
837+
try:
838+
if source.is_dir(follow_symlinks=follow_symlinks):
839+
stack.append((source, target_dir.joinpath(source.name)))
840+
else:
841+
source.copy(target_dir.joinpath(source.name),
842+
follow_symlinks=follow_symlinks)
843+
except OSError as err:
844+
on_error(err)
845+
except OSError as err:
846+
on_error(err)
847+
818848
def rename(self, target):
819849
"""
820850
Rename this path to the target path.

Lib/test/test_pathlib/test_pathlib.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,19 @@ def test_open_unbuffered(self):
653653
self.assertIsInstance(f, io.RawIOBase)
654654
self.assertEqual(f.read().strip(), b"this is file A")
655655

656+
@unittest.skipIf(sys.platform == "win32" or sys.platform == "wasi", "directories are always readable on Windows and WASI")
657+
def test_copytree_no_read_permission(self):
658+
base = self.cls(self.base)
659+
source = base / 'dirE'
660+
target = base / 'copyE'
661+
self.assertRaises(PermissionError, source.copytree, target)
662+
self.assertFalse(target.exists())
663+
errors = []
664+
source.copytree(target, on_error=errors.append)
665+
self.assertEqual(len(errors), 1)
666+
self.assertIsInstance(errors[0], PermissionError)
667+
self.assertFalse(target.exists())
668+
656669
def test_resolve_nonexist_relative_issue38671(self):
657670
p = self.cls('non', 'exist')
658671

Lib/test/test_pathlib/test_pathlib_abc.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1822,6 +1822,163 @@ def test_copy_empty(self):
18221822
self.assertTrue(target.exists())
18231823
self.assertEqual(target.read_bytes(), b'')
18241824

1825+
def test_copytree_simple(self):
1826+
base = self.cls(self.base)
1827+
source = base / 'dirC'
1828+
target = base / 'copyC'
1829+
source.copytree(target)
1830+
self.assertTrue(target.is_dir())
1831+
self.assertTrue(target.joinpath('dirD').is_dir())
1832+
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
1833+
self.assertEqual(target.joinpath('dirD', 'fileD').read_text(),
1834+
"this is file D\n")
1835+
self.assertTrue(target.joinpath('fileC').is_file())
1836+
self.assertTrue(target.joinpath('fileC').read_text(),
1837+
"this is file C\n")
1838+
1839+
def test_copytree_complex(self, follow_symlinks=True):
1840+
def ordered_walk(path):
1841+
for dirpath, dirnames, filenames in path.walk(follow_symlinks=follow_symlinks):
1842+
dirnames.sort()
1843+
filenames.sort()
1844+
yield dirpath, dirnames, filenames
1845+
base = self.cls(self.base)
1846+
source = base / 'dirC'
1847+
1848+
if self.can_symlink:
1849+
# Add some symlinks
1850+
source.joinpath('linkC').symlink_to('fileC')
1851+
source.joinpath('linkD').symlink_to('dirD')
1852+
1853+
# Perform the copy
1854+
target = base / 'copyC'
1855+
source.copytree(target, follow_symlinks=follow_symlinks)
1856+
1857+
# Compare the source and target trees
1858+
source_walk = ordered_walk(source)
1859+
target_walk = ordered_walk(target)
1860+
for source_item, target_item in zip(source_walk, target_walk, strict=True):
1861+
self.assertEqual(source_item[0].relative_to(source),
1862+
target_item[0].relative_to(target)) # dirpath
1863+
self.assertEqual(source_item[1], target_item[1]) # dirnames
1864+
self.assertEqual(source_item[2], target_item[2]) # filenames
1865+
# Compare files and symlinks
1866+
for filename in source_item[2]:
1867+
source_file = source_item[0].joinpath(filename)
1868+
target_file = target_item[0].joinpath(filename)
1869+
if follow_symlinks or not source_file.is_symlink():
1870+
# Regular file.
1871+
self.assertEqual(source_file.read_bytes(), target_file.read_bytes())
1872+
elif source_file.is_dir():
1873+
# Symlink to directory.
1874+
self.assertTrue(target_file.is_dir())
1875+
self.assertEqual(source_file.readlink(), target_file.readlink())
1876+
else:
1877+
# Symlink to file.
1878+
self.assertEqual(source_file.read_bytes(), target_file.read_bytes())
1879+
self.assertEqual(source_file.readlink(), target_file.readlink())
1880+
1881+
def test_copytree_complex_follow_symlinks_false(self):
1882+
self.test_copytree_complex(follow_symlinks=False)
1883+
1884+
def test_copytree_to_existing_directory(self):
1885+
base = self.cls(self.base)
1886+
source = base / 'dirC'
1887+
target = base / 'copyC'
1888+
target.mkdir()
1889+
target.joinpath('dirD').mkdir()
1890+
self.assertRaises(FileExistsError, source.copytree, target)
1891+
1892+
def test_copytree_to_existing_directory_dirs_exist_ok(self):
1893+
base = self.cls(self.base)
1894+
source = base / 'dirC'
1895+
target = base / 'copyC'
1896+
target.mkdir()
1897+
target.joinpath('dirD').mkdir()
1898+
source.copytree(target, dirs_exist_ok=True)
1899+
self.assertTrue(target.is_dir())
1900+
self.assertTrue(target.joinpath('dirD').is_dir())
1901+
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
1902+
self.assertEqual(target.joinpath('dirD', 'fileD').read_text(),
1903+
"this is file D\n")
1904+
self.assertTrue(target.joinpath('fileC').is_file())
1905+
self.assertTrue(target.joinpath('fileC').read_text(),
1906+
"this is file C\n")
1907+
1908+
def test_copytree_file(self):
1909+
base = self.cls(self.base)
1910+
source = base / 'fileA'
1911+
target = base / 'copyA'
1912+
self.assertRaises(NotADirectoryError, source.copytree, target)
1913+
1914+
def test_copytree_file_on_error(self):
1915+
base = self.cls(self.base)
1916+
source = base / 'fileA'
1917+
target = base / 'copyA'
1918+
errors = []
1919+
source.copytree(target, on_error=errors.append)
1920+
self.assertEqual(len(errors), 1)
1921+
self.assertIsInstance(errors[0], NotADirectoryError)
1922+
1923+
def test_copytree_ignore_false(self):
1924+
base = self.cls(self.base)
1925+
source = base / 'dirC'
1926+
target = base / 'copyC'
1927+
ignores = []
1928+
def ignore_false(path):
1929+
ignores.append(path)
1930+
return False
1931+
source.copytree(target, ignore=ignore_false)
1932+
self.assertEqual(set(ignores), {
1933+
source / 'dirD',
1934+
source / 'dirD' / 'fileD',
1935+
source / 'fileC',
1936+
source / 'novel.txt',
1937+
})
1938+
self.assertTrue(target.is_dir())
1939+
self.assertTrue(target.joinpath('dirD').is_dir())
1940+
self.assertTrue(target.joinpath('dirD', 'fileD').is_file())
1941+
self.assertEqual(target.joinpath('dirD', 'fileD').read_text(),
1942+
"this is file D\n")
1943+
self.assertTrue(target.joinpath('fileC').is_file())
1944+
self.assertTrue(target.joinpath('fileC').read_text(),
1945+
"this is file C\n")
1946+
1947+
def test_copytree_ignore_true(self):
1948+
base = self.cls(self.base)
1949+
source = base / 'dirC'
1950+
target = base / 'copyC'
1951+
ignores = []
1952+
def ignore_true(path):
1953+
ignores.append(path)
1954+
return True
1955+
source.copytree(target, ignore=ignore_true)
1956+
self.assertEqual(set(ignores), {
1957+
source / 'dirD',
1958+
source / 'fileC',
1959+
source / 'novel.txt',
1960+
})
1961+
self.assertTrue(target.is_dir())
1962+
self.assertFalse(target.joinpath('dirD').exists())
1963+
self.assertFalse(target.joinpath('fileC').exists())
1964+
self.assertFalse(target.joinpath('novel.txt').exists())
1965+
1966+
@needs_symlinks
1967+
def test_copytree_dangling_symlink(self):
1968+
base = self.cls(self.base)
1969+
source = base / 'source'
1970+
target = base / 'target'
1971+
1972+
source.mkdir()
1973+
source.joinpath('link').symlink_to('nonexistent')
1974+
1975+
self.assertRaises(FileNotFoundError, source.copytree, target)
1976+
1977+
target2 = base / 'target2'
1978+
source.copytree(target2, follow_symlinks=False)
1979+
self.assertTrue(target2.joinpath('link').is_symlink())
1980+
self.assertEqual(target2.joinpath('link').readlink(), self.cls('nonexistent'))
1981+
18251982
def test_iterdir(self):
18261983
P = self.cls
18271984
p = P(self.base)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add :meth:`pathlib.Path.copytree`, which recursively copies a directory.

0 commit comments

Comments
 (0)