https://github.com/python/cpython/commit/78e09a488d41066dea5f8dcf22405a0d5dd8be23 commit: 78e09a488d41066dea5f8dcf22405a0d5dd8be23 branch: main author: Barney Gale <barney.g...@gmail.com> committer: barneygale <barney.g...@gmail.com> date: 2025-02-24T19:10:50Z summary:
GH-125413: Fix stale metadata from `pathlib.Path.copy()` and `move()` (#130424) In `pathlib.Path.copy()` and `move()`, return a fresh `Path` object with an unpopulated `info` attribute, rather than a `Path` object with information recorded *prior* to the path's creation. files: A Misc/NEWS.d/next/Library/2025-02-21-21-50-21.gh-issue-125413.DEAD0L.rst M Lib/pathlib/_abc.py M Lib/pathlib/_local.py M Lib/test/test_pathlib/test_pathlib_abc.py diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index 4106d478822084..97d78f557d14dc 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -353,7 +353,8 @@ def copy(self, target, follow_symlinks=True, dirs_exist_ok=False, create = target._copy_writer._create except AttributeError: raise TypeError(f"Target is not writable: {target}") from None - return create(self, follow_symlinks, dirs_exist_ok, preserve_metadata) + create(self, follow_symlinks, dirs_exist_ok, preserve_metadata) + return target.joinpath() # Empty join to ensure fresh metadata. def copy_into(self, target_dir, *, follow_symlinks=True, dirs_exist_ok=False, preserve_metadata=False): diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index 9b2738652e9754..0ae85a68b3b2c1 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -1098,7 +1098,8 @@ def copy(self, target, follow_symlinks=True, dirs_exist_ok=False, create = target._copy_writer._create except AttributeError: raise TypeError(f"Target is not writable: {target}") from None - return create(self, follow_symlinks, dirs_exist_ok, preserve_metadata) + create(self, follow_symlinks, dirs_exist_ok, preserve_metadata) + return target.joinpath() # Empty join to ensure fresh metadata. def copy_into(self, target_dir, *, follow_symlinks=True, dirs_exist_ok=False, preserve_metadata=False): @@ -1128,10 +1129,12 @@ def move(self, target): else: ensure_different_files(self, target) try: - return self.replace(target) + os.replace(self, target) except OSError as err: if err.errno != EXDEV: raise + else: + return target.joinpath() # Empty join to ensure fresh metadata. # Fall back to copy+delete. target = self.copy(target, follow_symlinks=False, preserve_metadata=True) self._delete() diff --git a/Lib/test/test_pathlib/test_pathlib_abc.py b/Lib/test/test_pathlib/test_pathlib_abc.py index 68fe3521410f25..a6e3e0709833a3 100644 --- a/Lib/test/test_pathlib/test_pathlib_abc.py +++ b/Lib/test/test_pathlib/test_pathlib_abc.py @@ -1391,8 +1391,8 @@ def test_copy_file(self): target = base / 'copyA' result = source.copy(target) self.assertEqual(result, target) - self.assertTrue(target.exists()) - self.assertEqual(source.read_text(), target.read_text()) + self.assertTrue(result.info.exists()) + self.assertEqual(source.read_text(), result.read_text()) def test_copy_file_to_existing_file(self): base = self.cls(self.base) @@ -1400,8 +1400,8 @@ def test_copy_file_to_existing_file(self): target = base / 'dirB' / 'fileB' result = source.copy(target) self.assertEqual(result, target) - self.assertTrue(target.exists()) - self.assertEqual(source.read_text(), target.read_text()) + self.assertTrue(result.info.exists()) + self.assertEqual(source.read_text(), result.read_text()) def test_copy_file_to_existing_directory(self): base = self.cls(self.base) @@ -1416,8 +1416,8 @@ def test_copy_file_empty(self): source.write_bytes(b'') result = source.copy(target) self.assertEqual(result, target) - self.assertTrue(target.exists()) - self.assertEqual(target.read_bytes(), b'') + self.assertTrue(result.info.exists()) + self.assertEqual(result.read_bytes(), b'') def test_copy_file_to_itself(self): base = self.cls(self.base) @@ -1432,13 +1432,13 @@ def test_copy_dir_simple(self): target = base / 'copyC' result = source.copy(target) self.assertEqual(result, target) - self.assertTrue(target.is_dir()) - self.assertTrue(target.joinpath('dirD').is_dir()) - self.assertTrue(target.joinpath('dirD', 'fileD').is_file()) - self.assertEqual(target.joinpath('dirD', 'fileD').read_text(), + self.assertTrue(result.info.is_dir()) + self.assertTrue(result.joinpath('dirD').info.is_dir()) + self.assertTrue(result.joinpath('dirD', 'fileD').info.is_file()) + self.assertEqual(result.joinpath('dirD', 'fileD').read_text(), "this is file D\n") - self.assertTrue(target.joinpath('fileC').is_file()) - self.assertTrue(target.joinpath('fileC').read_text(), + self.assertTrue(result.joinpath('fileC').info.is_file()) + self.assertTrue(result.joinpath('fileC').read_text(), "this is file C\n") def test_copy_dir_complex(self, follow_symlinks=True): @@ -1462,7 +1462,7 @@ def ordered_walk(path): # Compare the source and target trees source_walk = ordered_walk(source) - target_walk = ordered_walk(target) + target_walk = ordered_walk(result) for source_item, target_item in zip(source_walk, target_walk, strict=True): self.assertEqual(source_item[0].parts[len(source.parts):], target_item[0].parts[len(target.parts):]) # dirpath @@ -1472,12 +1472,12 @@ def ordered_walk(path): for filename in source_item[2]: source_file = source_item[0].joinpath(filename) target_file = target_item[0].joinpath(filename) - if follow_symlinks or not source_file.is_symlink(): + if follow_symlinks or not source_file.info.is_symlink(): # Regular file. self.assertEqual(source_file.read_bytes(), target_file.read_bytes()) - elif source_file.is_dir(): + elif source_file.info.is_dir(): # Symlink to directory. - self.assertTrue(target_file.is_dir()) + self.assertTrue(target_file.info.is_dir()) self.assertEqual(source_file.readlink(), target_file.readlink()) else: # Symlink to file. @@ -1503,13 +1503,13 @@ def test_copy_dir_to_existing_directory_dirs_exist_ok(self): target.joinpath('dirD').mkdir() result = source.copy(target, dirs_exist_ok=True) self.assertEqual(result, target) - self.assertTrue(target.is_dir()) - self.assertTrue(target.joinpath('dirD').is_dir()) - self.assertTrue(target.joinpath('dirD', 'fileD').is_file()) - self.assertEqual(target.joinpath('dirD', 'fileD').read_text(), + self.assertTrue(result.info.is_dir()) + self.assertTrue(result.joinpath('dirD').info.is_dir()) + self.assertTrue(result.joinpath('dirD', 'fileD').info.is_file()) + self.assertEqual(result.joinpath('dirD', 'fileD').read_text(), "this is file D\n") - self.assertTrue(target.joinpath('fileC').is_file()) - self.assertTrue(target.joinpath('fileC').read_text(), + self.assertTrue(result.joinpath('fileC').info.is_file()) + self.assertTrue(result.joinpath('fileC').read_text(), "this is file C\n") def test_copy_dir_to_itself(self): @@ -1524,7 +1524,7 @@ def test_copy_dir_into_itself(self): target = base / 'dirC' / 'dirD' / 'copyC' self.assertRaises(OSError, source.copy, target) self.assertRaises(OSError, source.copy, target, follow_symlinks=False) - self.assertFalse(target.exists()) + self.assertFalse(target.info.exists()) def test_copy_into(self): base = self.cls(self.base) @@ -1532,7 +1532,7 @@ def test_copy_into(self): target_dir = base / 'dirA' result = source.copy_into(target_dir) self.assertEqual(result, target_dir / 'fileA') - self.assertTrue(result.exists()) + self.assertTrue(result.info.exists()) self.assertEqual(source.read_text(), result.read_text()) def test_copy_into_empty_name(self): diff --git a/Misc/NEWS.d/next/Library/2025-02-21-21-50-21.gh-issue-125413.DEAD0L.rst b/Misc/NEWS.d/next/Library/2025-02-21-21-50-21.gh-issue-125413.DEAD0L.rst new file mode 100644 index 00000000000000..87ed43ceb69b8c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-02-21-21-50-21.gh-issue-125413.DEAD0L.rst @@ -0,0 +1,2 @@ +Ensure the path returned from :meth:`pathlib.Path.copy` or +:meth:`~pathlib.Path.move` has fresh :attr:`~pathlib.Path.info`. _______________________________________________ Python-checkins mailing list -- python-checkins@python.org To unsubscribe send an email to python-checkins-le...@python.org https://mail.python.org/mailman3/lists/python-checkins.python.org/ Member address: arch...@mail-archive.com