From d5367462c1ba04dbfb930cd986fe979f54738878 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 18:53:26 +0100 Subject: [PATCH 1/8] tests: add a str tmpdir test case for copy_into --- upath/tests/cases.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/upath/tests/cases.py b/upath/tests/cases.py index dddb9852..573b5981 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -551,6 +551,16 @@ def test_copy_local(self, tmp_path: Path): assert target.exists() assert target.read_text() == content + def test_copy_into__file_to_str_tempdir(self, tmp_path: Path): + target_dir = str(tmp_path / "target-dir") + + source = self.path_file + source.copy_into(target_dir) + target = tmp_path.joinpath("target-dir", source.name) + + assert target.exists() + assert target.read_text() == source.read_text() + def test_copy_into_local(self, tmp_path: Path): target_dir = UPath(tmp_path) / "target-dir" target_dir.mkdir() From 783652ccd086a642abd97433a209095b0df19c8a Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 22:20:13 +0100 Subject: [PATCH 2/8] test: add dir copy/copy_into test --- upath/tests/cases.py | 24 ++++++++++++++++++++++-- upath/tests/implementations/test_data.py | 5 +++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/upath/tests/cases.py b/upath/tests/cases.py index 573b5981..1bd875dc 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -552,15 +552,35 @@ def test_copy_local(self, tmp_path: Path): assert target.read_text() == content def test_copy_into__file_to_str_tempdir(self, tmp_path: Path): - target_dir = str(tmp_path / "target-dir") + tmp_path = tmp_path.joinpath("somewhere") + tmp_path.mkdir() + target_dir = str(tmp_path) source = self.path_file source.copy_into(target_dir) - target = tmp_path.joinpath("target-dir", source.name) + target = tmp_path.joinpath(source.name) assert target.exists() assert target.read_text() == source.read_text() + def test_copy_into__dir_to_str_tempdir(self, tmp_path: Path): + tmp_path = tmp_path.joinpath("somewhere") + tmp_path.mkdir() + target_dir = str(tmp_path) + + source_dir = self.path.joinpath("folder1") + assert source_dir.is_dir() + source_dir.copy_into(target_dir) + target = tmp_path.joinpath(source_dir.name) + + assert target.exists() + assert target.is_dir() + for item in source_dir.iterdir(): + target_item = target.joinpath(item.name) + assert target_item.exists() + if item.is_file(): + assert target_item.read_text() == item.read_text() + def test_copy_into_local(self, tmp_path: Path): target_dir = UPath(tmp_path) / "target-dir" target_dir.mkdir() diff --git a/upath/tests/implementations/test_data.py b/upath/tests/implementations/test_data.py index a9e2f4b8..d478559f 100644 --- a/upath/tests/implementations/test_data.py +++ b/upath/tests/implementations/test_data.py @@ -253,3 +253,8 @@ def test_unlink(self): # DataPaths can't be deleted with pytest.raises(UnsupportedOperation): self.path_file.unlink() + + @overrides_base + def test_copy_into__dir_to_str_tempdir(self): + # There are no directories in DataPath + assert not self.path.is_dir() From f985ba9d8fa13ccf9e504e5eb69ec85e99f270c3 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 23:28:55 +0100 Subject: [PATCH 3/8] upath.core: fix copy / copy_into / move / move_into --- upath/core.py | 37 ++++++++++++++++++++++++++-------- upath/implementations/local.py | 32 +++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/upath/core.py b/upath/core.py index 7c28ba00..ba840b1d 100644 --- a/upath/core.py +++ b/upath/core.py @@ -1221,10 +1221,17 @@ def copy(self, target: _WT | SupportsPathLike | str, **kwargs: Any) -> _WT | UPa """ Recursively copy this file or directory tree to the given destination. """ - if not isinstance(target, UPath): - return super().copy(self.with_segments(target), **kwargs) - else: - return super().copy(target, **kwargs) + if isinstance(target, str): + proto = get_upath_protocol(target) + if proto != self.protocol: + target = UPath(target) + else: + target = self.with_segments(target) + elif not isinstance(target, UPath): + target = self.with_segments(str(target)) + if target.exists(): + raise FileExistsError(str(target)) + return super().copy(target, **kwargs) @overload def copy_into(self, target_dir: _WT, **kwargs: Any) -> _WT: ... @@ -1238,10 +1245,19 @@ def copy_into( """ Copy this file or directory tree into the given existing directory. """ - if not isinstance(target_dir, UPath): - return super().copy_into(self.with_segments(target_dir), **kwargs) - else: - return super().copy_into(target_dir, **kwargs) + if isinstance(target_dir, str): + proto = get_upath_protocol(target_dir) + if proto != self.protocol: + target_dir = UPath(target_dir) + else: + target_dir = self.with_segments(target_dir) + elif not isinstance(target_dir, UPath): + target_dir = self.with_segments(str(target_dir)) + if not target_dir.exists(): + raise FileNotFoundError(str(target_dir)) + if not target_dir.is_dir(): + raise NotADirectoryError(str(target_dir)) + return super().copy_into(target_dir, **kwargs) @overload def move(self, target: _WT, **kwargs: Any) -> _WT: ... @@ -1276,6 +1292,11 @@ def move_into( target = target_dir.with_segments(target_dir, name) # type: ignore else: target = self.with_segments(target_dir, name) + td = target.parent + if not td.exists(): + raise FileNotFoundError(str(td)) + elif not td.is_dir(): + raise NotADirectoryError(str(td)) return self.move(target) def _copy_from( diff --git a/upath/implementations/local.py b/upath/implementations/local.py index 739c78aa..770ce239 100644 --- a/upath/implementations/local.py +++ b/upath/implementations/local.py @@ -21,6 +21,7 @@ from upath._chain import ChainSegment from upath._chain import FSSpecChainParser from upath._protocol import compatible_protocol +from upath._protocol import get_upath_protocol from upath.core import UnsupportedOperation from upath.core import UPath from upath.core import _UPathMixin @@ -377,10 +378,15 @@ def copy( # hacky workaround for missing pathlib.Path.copy in python < 3.14 # todo: revisit _copy: Any = ReadablePath.copy.__get__(self) - if not isinstance(target, UPath): - return _copy(self.with_segments(str(target)), **kwargs) - else: - return _copy(target, **kwargs) + if isinstance(target, str): + proto = get_upath_protocol(target) + if proto != self.protocol: + target = UPath(target) + else: + target = self.with_segments(target) + elif not isinstance(target, UPath): + target = self.with_segments(str(target)) + return _copy(target, **kwargs) @overload def copy_into(self, target_dir: _WT, **kwargs: Any) -> _WT: ... @@ -398,10 +404,15 @@ def copy_into( # hacky workaround for missing pathlib.Path.copy_into in python < 3.14 # todo: revisit _copy_into: Any = ReadablePath.copy_into.__get__(self) - if not isinstance(target_dir, UPath): - return _copy_into(self.with_segments(str(target_dir)), **kwargs) - else: - return _copy_into(target_dir, **kwargs) + if isinstance(target_dir, str): + proto = get_upath_protocol(target_dir) + if proto != self.protocol: + target_dir = UPath(target_dir) + else: + target_dir = self.with_segments(target_dir) + elif not isinstance(target_dir, UPath): + target_dir = self.with_segments(str(target_dir)) + return _copy_into(target_dir, **kwargs) @overload def move(self, target: _WT, **kwargs: Any) -> _WT: ... @@ -434,6 +445,11 @@ def move_into( target = target_dir.with_segments(str(target_dir), name) # type: ignore else: target = self.with_segments(str(target_dir), name) + td = target.parent + if not td.exists(): + raise FileNotFoundError(str(td)) + elif not td.is_dir(): + raise NotADirectoryError(str(td)) return self.move(target) @property From 4e8bd93c62bef18da85bea6c488f7e71620f99ca Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 23:45:57 +0100 Subject: [PATCH 4/8] tests: check copy and copy_into also for Path and UPath --- upath/tests/cases.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/upath/tests/cases.py b/upath/tests/cases.py index 1bd875dc..175bc2e9 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -551,10 +551,12 @@ def test_copy_local(self, tmp_path: Path): assert target.exists() assert target.read_text() == content - def test_copy_into__file_to_str_tempdir(self, tmp_path: Path): + @pytest.mark.parametrize("target_type", [str, Path, UPath]) + def test_copy_into__file_to_str_tempdir(self, tmp_path: Path, target_type): tmp_path = tmp_path.joinpath("somewhere") tmp_path.mkdir() - target_dir = str(tmp_path) + target_dir = target_type(tmp_path) + assert isinstance(target_dir, target_type) source = self.path_file source.copy_into(target_dir) @@ -563,10 +565,12 @@ def test_copy_into__file_to_str_tempdir(self, tmp_path: Path): assert target.exists() assert target.read_text() == source.read_text() - def test_copy_into__dir_to_str_tempdir(self, tmp_path: Path): + @pytest.mark.parametrize("target_type", [str, Path, UPath]) + def test_copy_into__dir_to_str_tempdir(self, tmp_path: Path, target_type): tmp_path = tmp_path.joinpath("somewhere") tmp_path.mkdir() - target_dir = str(tmp_path) + target_dir = target_type(tmp_path) + assert isinstance(target_dir, target_type) source_dir = self.path.joinpath("folder1") assert source_dir.is_dir() From fb95fd9548a9272169076517089a3e2748b6c9af Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 23:46:31 +0100 Subject: [PATCH 5/8] upath.core: fix Path handling in copy and copy_into --- upath/core.py | 6 ++++-- upath/implementations/local.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/upath/core.py b/upath/core.py index ba840b1d..eaa5acf7 100644 --- a/upath/core.py +++ b/upath/core.py @@ -1228,7 +1228,7 @@ def copy(self, target: _WT | SupportsPathLike | str, **kwargs: Any) -> _WT | UPa else: target = self.with_segments(target) elif not isinstance(target, UPath): - target = self.with_segments(str(target)) + target = UPath(target) if target.exists(): raise FileExistsError(str(target)) return super().copy(target, **kwargs) @@ -1252,7 +1252,7 @@ def copy_into( else: target_dir = self.with_segments(target_dir) elif not isinstance(target_dir, UPath): - target_dir = self.with_segments(str(target_dir)) + target_dir = UPath(target_dir) if not target_dir.exists(): raise FileNotFoundError(str(target_dir)) if not target_dir.is_dir(): @@ -1290,6 +1290,8 @@ def move_into( raise ValueError(f"{self!r} has an empty name") elif hasattr(target_dir, "with_segments"): target = target_dir.with_segments(target_dir, name) # type: ignore + elif isinstance(target_dir, PurePath): + target = UPath(target_dir, name) else: target = self.with_segments(target_dir, name) td = target.parent diff --git a/upath/implementations/local.py b/upath/implementations/local.py index 770ce239..251ab3cc 100644 --- a/upath/implementations/local.py +++ b/upath/implementations/local.py @@ -385,7 +385,7 @@ def copy( else: target = self.with_segments(target) elif not isinstance(target, UPath): - target = self.with_segments(str(target)) + target = UPath(target) return _copy(target, **kwargs) @overload @@ -411,7 +411,7 @@ def copy_into( else: target_dir = self.with_segments(target_dir) elif not isinstance(target_dir, UPath): - target_dir = self.with_segments(str(target_dir)) + target_dir = UPath(target_dir) return _copy_into(target_dir, **kwargs) @overload @@ -443,6 +443,8 @@ def move_into( raise ValueError(f"{self!r} has an empty name") elif hasattr(target_dir, "with_segments"): target = target_dir.with_segments(str(target_dir), name) # type: ignore + elif isinstance(target_dir, pathlib.PurePath): + target = UPath(target_dir, name) else: target = self.with_segments(str(target_dir), name) td = target.parent From 20dcaa5a7d1b342d6e398c94d8701fb7fbc182d9 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Tue, 6 Jan 2026 00:10:15 +0100 Subject: [PATCH 6/8] tests: add exception cases for copy / copy_into --- upath/tests/cases.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/upath/tests/cases.py b/upath/tests/cases.py index 175bc2e9..7d171876 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -615,6 +615,30 @@ def test_copy_into_memory(self, clear_fsspec_memory_cache): assert target.exists() assert target.read_text() == content + def test_copy_exceptions(self, tmp_path: Path): + source = self.path_file + # target is a directory + target = UPath(tmp_path) / "target-folder" + target.mkdir() + with pytest.raises(IsADirectoryError): + source.copy(target) + # target parent does not exist + target = UPath(tmp_path) / "nonexistent-dir" / "target-file1.txt" + with pytest.raises(FileNotFoundError): + source.copy(target) + + def test_copy_into_exceptions(self, tmp_path: Path): + source = self.path_file + # target is not a directory + target_file = UPath(tmp_path) / "target-file.txt" + target_file.write_text("content") + with pytest.raises(NotADirectoryError): + source.copy_into(target_file) + # target dir does not exist + target_dir = UPath(tmp_path) / "nonexistent-dir" + with pytest.raises(FileNotFoundError): + source.copy_into(target_dir) + def test_read_with_fsspec(self): p = self.path_file From b75e7f1cccfed823cdbd0a504fb5528762e5a45f Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Tue, 6 Jan 2026 00:10:41 +0100 Subject: [PATCH 7/8] upath.core: raise IsADirectoryError in copy --- upath/core.py | 4 ++-- upath/implementations/local.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/upath/core.py b/upath/core.py index eaa5acf7..d572a4c9 100644 --- a/upath/core.py +++ b/upath/core.py @@ -1229,8 +1229,8 @@ def copy(self, target: _WT | SupportsPathLike | str, **kwargs: Any) -> _WT | UPa target = self.with_segments(target) elif not isinstance(target, UPath): target = UPath(target) - if target.exists(): - raise FileExistsError(str(target)) + if target.is_dir(): + raise IsADirectoryError(str(target)) return super().copy(target, **kwargs) @overload diff --git a/upath/implementations/local.py b/upath/implementations/local.py index 251ab3cc..3bf3ec76 100644 --- a/upath/implementations/local.py +++ b/upath/implementations/local.py @@ -386,6 +386,8 @@ def copy( target = self.with_segments(target) elif not isinstance(target, UPath): target = UPath(target) + if target.is_dir(): + raise IsADirectoryError(str(target)) return _copy(target, **kwargs) @overload From 2796b6c9575f2e2f90d450edb4602ffe79ba4668 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Tue, 6 Jan 2026 00:28:40 +0100 Subject: [PATCH 8/8] tests: adjust test cases to OSError for windows cpython also only checks for OSError in the test suite... --- upath/tests/cases.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/upath/tests/cases.py b/upath/tests/cases.py index 7d171876..22587fa9 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -620,7 +620,8 @@ def test_copy_exceptions(self, tmp_path: Path): # target is a directory target = UPath(tmp_path) / "target-folder" target.mkdir() - with pytest.raises(IsADirectoryError): + # FIXME: pytest.raises(IsADirectoryError) not working on Windows + with pytest.raises(OSError): source.copy(target) # target parent does not exist target = UPath(tmp_path) / "nonexistent-dir" / "target-file1.txt" @@ -632,7 +633,8 @@ def test_copy_into_exceptions(self, tmp_path: Path): # target is not a directory target_file = UPath(tmp_path) / "target-file.txt" target_file.write_text("content") - with pytest.raises(NotADirectoryError): + # FIXME: pytest.raises(NotADirectoryError) not working on Windows + with pytest.raises(OSError): source.copy_into(target_file) # target dir does not exist target_dir = UPath(tmp_path) / "nonexistent-dir"