Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 35e998f

Browse filesBrowse 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 <nineteendo19d0@gmail.com>
1 parent bc37ac7 commit 35e998f
Copy full SHA for 35e998f

File tree

Expand file treeCollapse file tree

6 files changed

+231
-0
lines changed
Filter options
Expand file treeCollapse file tree

6 files changed

+231
-0
lines changed

‎Doc/library/pathlib.rst

Copy file name to clipboardExpand all lines: Doc/library/pathlib.rst
+27Lines 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

Copy file name to clipboardExpand all lines: Doc/whatsnew/3.14.rst
+3Lines 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

Copy file name to clipboardExpand all lines: Lib/pathlib/_abc.py
+30Lines 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

Copy file name to clipboardExpand all lines: Lib/test/test_pathlib/test_pathlib.py
+13Lines 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

Copy file name to clipboardExpand all lines: Lib/test/test_pathlib/test_pathlib_abc.py
+157Lines 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)
+1Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.