Skip to content

Commit 3d0bb4c

Browse files
authored
Require .cwd() for relative paths in .rename() (#493)
* tests: update rename cases * tests: ftp_server is now function scoped * tests: update test cases * upath.core: fix rename implementation for relative paths * upath.extensions: fix .cwd() behavior for ProxyUPath * upath.implementations.ftp: fix dircache issue on .rename * upath.extensions: fix typing of .cwd() * upath.implementations.local: fix rename return type on python 3.14+ * tests: on windows set cwd to the tmpdir path * tests: update rename and tests
1 parent 0a1f78c commit 3d0bb4c

File tree

14 files changed

+300
-65
lines changed

14 files changed

+300
-65
lines changed

upath/core.py

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import posixpath
65
import sys
76
import warnings
87
from abc import ABCMeta
@@ -11,6 +10,7 @@
1110
from collections.abc import Mapping
1211
from collections.abc import Sequence
1312
from copy import copy
13+
from pathlib import PurePath
1414
from types import MappingProxyType
1515
from typing import IO
1616
from typing import TYPE_CHECKING
@@ -336,7 +336,7 @@ def path(self) -> str:
336336
if (self_path := str(self)) == ".":
337337
path = str(current_dir)
338338
else:
339-
path = current_dir.parser.join(str(self), self_path)
339+
path = current_dir.parser.join(str(current_dir), self_path)
340340
return self.parser.strip_protocol(path)
341341
return self._chain.active_path
342342

@@ -1809,6 +1809,12 @@ def rename(
18091809
18101810
Returns the new Path instance pointing to the target path.
18111811
1812+
Info
1813+
----
1814+
For filesystems that don't have a root character, i.e. for which
1815+
relative paths can be ambiguous, you can explicitly indicate a
1816+
relative path via prefixing with `./`
1817+
18121818
Warning
18131819
-------
18141820
This method is non-standard compared to pathlib.Path.rename(),
@@ -1819,43 +1825,53 @@ def rename(
18191825
running into future compatibility issues.
18201826
18211827
"""
1828+
# check protocol compatibility
18221829
target_protocol = get_upath_protocol(target)
18231830
if target_protocol and target_protocol != self.protocol:
18241831
raise ValueError(
18251832
f"expected protocol {self.protocol!r}, got: {target_protocol!r}"
18261833
)
1827-
if not isinstance(target, UPath):
1828-
target = str(target)
1829-
if target_protocol or (self.anchor and target.startswith(self.anchor)):
1830-
target = self.with_segments(target)
1834+
# ensure target is an absolute UPath
1835+
if not isinstance(target, type(self)):
1836+
if isinstance(target, (UPath, PurePath)):
1837+
target_str = target.as_posix()
1838+
else:
1839+
target_str = str(target)
1840+
if target_protocol:
1841+
# target protocol provided indicates absolute path
1842+
target = self.with_segments(target_str)
1843+
elif self.anchor and target_str.startswith(self.anchor):
1844+
# self.anchor can be used to indicate absolute path
1845+
target = self.with_segments(target_str)
1846+
elif not self.anchor and target_str.startswith("./"):
1847+
# indicate relative via "./"
1848+
target = (
1849+
self.cwd()
1850+
.joinpath(target_str.removeprefix("./"))
1851+
.relative_to(self.cwd())
1852+
)
18311853
else:
1832-
target = UPath(target)
1854+
# all other cases
1855+
target = self.cwd().joinpath(target_str).relative_to(self.cwd())
1856+
# return early if renaming to same path
18331857
if target == self:
18341858
return self
1835-
if self._relative_base is not None:
1836-
self = self.absolute()
1837-
target_protocol = get_upath_protocol(target)
1838-
if target_protocol:
1839-
target_ = target
1840-
# avoid calling .resolve for subclasses of UPath
1841-
if ".." in target_.parts or "." in target_.parts:
1842-
target_ = target_.resolve()
1843-
else:
1844-
parent = self.parent
1845-
# avoid calling .resolve for subclasses of UPath
1846-
if ".." in parent.parts or "." in parent.parts:
1847-
parent = parent.resolve()
1848-
target_ = parent.joinpath(posixpath.normpath(target.path))
1859+
# ensure source and target are absolute
1860+
source_abs = self.absolute()
1861+
target_abs = target.absolute()
1862+
# avoid calling .resolve for if not needed
1863+
if ".." in target_abs.parts or "." in target_abs.parts:
1864+
target_abs = target_abs.resolve()
18491865
if recursive is not UNSET_DEFAULT:
18501866
kwargs["recursive"] = recursive
18511867
if maxdepth is not UNSET_DEFAULT:
18521868
kwargs["maxdepth"] = maxdepth
18531869
self.fs.mv(
1854-
self.path,
1855-
target_.path,
1870+
source_abs.path,
1871+
target_abs.path,
18561872
**kwargs,
18571873
)
1858-
return self.with_segments(target_)
1874+
return target
18591875

18601876
def replace(self, target: WritablePathLike) -> Self:
18611877
"""

upath/extensions.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Callable
1212
from typing import Literal
1313
from typing import TextIO
14+
from typing import TypeVar
1415
from typing import overload
1516
from urllib.parse import SplitResult
1617

@@ -40,6 +41,28 @@
4041
"ProxyUPath",
4142
]
4243

44+
T = TypeVar("T")
45+
46+
47+
class classmethod_or_method(classmethod):
48+
"""A decorator that can be used as a classmethod or an instance method.
49+
50+
When called on the class, it behaves like a classmethod.
51+
When called on an instance, it behaves like an instance method.
52+
53+
"""
54+
55+
def __get__(
56+
self,
57+
instance: T | None,
58+
owner: type[T] | None = None,
59+
/,
60+
) -> Callable[..., T]:
61+
if instance is None:
62+
return self.__func__.__get__(owner)
63+
else:
64+
return self.__func__.__get__(instance)
65+
4366

4467
class ProxyUPath:
4568
"""ProxyUPath base class
@@ -380,9 +403,12 @@ def as_posix(self) -> str:
380403
def samefile(self, other_path) -> bool:
381404
return self.__wrapped__.samefile(other_path)
382405

383-
@classmethod
384-
def cwd(cls) -> Self:
385-
raise UnsupportedOperation(".cwd() not supported")
406+
@classmethod_or_method
407+
def cwd(cls_or_self) -> Self: # noqa: B902
408+
if isinstance(cls_or_self, type):
409+
raise UnsupportedOperation(".cwd() not supported")
410+
else:
411+
return cls_or_self._from_upath(cls_or_self.__wrapped__.cwd())
386412

387413
@classmethod
388414
def home(cls) -> Self:

upath/implementations/ftp.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
from typing import TYPE_CHECKING
77

88
from upath.core import UPath
9+
from upath.types import UNSET_DEFAULT
910
from upath.types import JoinablePathLike
1011

1112
if TYPE_CHECKING:
13+
from typing import Any
1214
from typing import Literal
1315

1416
if sys.version_info >= (3, 11):
@@ -19,6 +21,7 @@
1921
from typing_extensions import Unpack
2022

2123
from upath._chain import FSSpecChainParser
24+
from upath.types import WritablePathLike
2225
from upath.types.storage_options import FTPStorageOptions
2326

2427
__all__ = ["FTPPath"]
@@ -55,3 +58,17 @@ def iterdir(self) -> Iterator[Self]:
5558
raise NotADirectoryError(str(self))
5659
else:
5760
return super().iterdir()
61+
62+
def rename(
63+
self,
64+
target: WritablePathLike,
65+
*, # note: non-standard compared to pathlib
66+
recursive: bool = UNSET_DEFAULT,
67+
maxdepth: int | None = UNSET_DEFAULT,
68+
**kwargs: Any,
69+
) -> Self:
70+
t = super().rename(target, recursive=recursive, maxdepth=maxdepth, **kwargs)
71+
self_dir = self.parent.path
72+
t.fs.invalidate_cache(self_dir)
73+
self.fs.invalidate_cache(self_dir)
74+
return t

upath/implementations/local.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from upath.types import StatResultType
3333
from upath.types import SupportsPathLike
3434
from upath.types import WritablePath
35+
from upath.types import WritablePathLike
3536

3637
if TYPE_CHECKING:
3738
from typing import IO
@@ -133,6 +134,18 @@ def _raw_urlpaths(self) -> Sequence[JoinablePathLike]:
133134
def _raw_urlpaths(self, value: Sequence[JoinablePathLike]) -> None:
134135
pass
135136

137+
if sys.version_info >= (3, 14):
138+
139+
def rename(
140+
self,
141+
target: WritablePathLike,
142+
) -> Self:
143+
t = super().rename(target) # type: ignore[arg-type]
144+
if not isinstance(target, type(self)):
145+
return self.with_segments(t)
146+
else:
147+
return t
148+
136149
if sys.version_info >= (3, 12):
137150

138151
def __init__(

upath/tests/cases.py

Lines changed: 96 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from upath import UnsupportedOperation
1414
from upath import UPath
15+
from upath._protocol import get_upath_protocol
1516
from upath._stat import UPathStatResult
1617
from upath.types import StatResultType
1718

@@ -275,31 +276,101 @@ def test_readlink(self):
275276
self.path.readlink()
276277

277278
def test_rename(self):
278-
upath = self.path.joinpath("file1.txt")
279-
target = upath.parent.joinpath("file1_renamed.txt")
280-
moved = upath.rename(target)
281-
assert target == moved
282-
assert not upath.exists()
283-
assert moved.exists()
284-
# reverse with an absolute path as str
285-
back = moved.rename(upath.path)
286-
assert back == upath
287-
assert not moved.exists()
288-
assert back.exists()
289-
290-
def test_rename2(self):
291-
upath = self.path.joinpath("folder1/file2.txt")
292-
target = "file2_renamed.txt"
293-
moved = upath.rename(target)
294-
target_path = upath.parent.joinpath(target).resolve()
295-
assert target_path == moved
296-
assert not upath.exists()
297-
assert moved.exists()
298-
# reverse with a relative path as UPath
299-
back = moved.rename(UPath("file2.txt"))
300-
assert back == upath
301-
assert not moved.exists()
302-
assert back.exists()
279+
p_source = self.path.joinpath("file1.txt")
280+
p_target = self.path.joinpath("file1_renamed.txt")
281+
282+
p_moved = p_source.rename(p_target)
283+
assert p_target == p_moved
284+
assert not p_source.exists()
285+
assert p_moved.exists()
286+
287+
p_revert = p_moved.rename(p_source)
288+
assert p_revert == p_source
289+
assert not p_moved.exists()
290+
assert p_revert.exists()
291+
292+
@pytest.fixture
293+
def supports_cwd(self):
294+
# intentionally called on the instance to support ProxyUPath().cwd()
295+
try:
296+
self.path.cwd()
297+
except UnsupportedOperation:
298+
return False
299+
else:
300+
return True
301+
302+
@pytest.mark.parametrize(
303+
"target_factory",
304+
[
305+
lambda obj, name: name,
306+
lambda obj, name: UPath(name),
307+
lambda obj, name: Path(name),
308+
lambda obj, name: obj.joinpath(name).relative_to(obj),
309+
],
310+
ids=[
311+
"str_relative",
312+
"plain_upath_relative",
313+
"plain_path_relative",
314+
"self_upath_relative",
315+
],
316+
)
317+
def test_rename_with_target_relative(
318+
self, request, monkeypatch, supports_cwd, target_factory, tmp_path
319+
):
320+
source = self.path.joinpath("folder1/file2.txt")
321+
target = target_factory(self.path, "file2_renamed.txt")
322+
323+
source_text = source.read_text()
324+
if supports_cwd:
325+
cid = request.node.callspec.id
326+
cwd = tmp_path.joinpath(cid)
327+
cwd.mkdir(parents=True, exist_ok=True)
328+
monkeypatch.chdir(cwd)
329+
330+
t = source.rename(target)
331+
assert (t.protocol == UPath(target).protocol) or UPath(
332+
target
333+
).protocol == ""
334+
assert (t.path == UPath(target).path) or (
335+
t.path == UPath(target).absolute().path
336+
)
337+
assert t.exists()
338+
assert t.read_text() == source_text
339+
340+
else:
341+
with pytest.raises(UnsupportedOperation):
342+
source.rename(target)
343+
344+
@pytest.mark.parametrize(
345+
"target_factory",
346+
[
347+
lambda obj, name: obj.joinpath(name).absolute().as_posix(),
348+
lambda obj, name: UPath(obj.absolute().joinpath(name).path),
349+
lambda obj, name: Path(obj.absolute().joinpath(name).path),
350+
lambda obj, name: obj.absolute().joinpath(name),
351+
],
352+
ids=[
353+
"str_absolute",
354+
"plain_upath_absolute",
355+
"plain_path_absolute",
356+
"self_upath_absolute",
357+
],
358+
)
359+
def test_rename_with_target_absolute(self, target_factory):
360+
from upath._chain import Chain
361+
from upath._chain import FSSpecChainParser
362+
363+
source = self.path.joinpath("folder1/file2.txt")
364+
target = target_factory(self.path, "file2_renamed.txt")
365+
366+
source_text = source.read_text()
367+
t = source.rename(target)
368+
assert get_upath_protocol(target) in {t.protocol, ""}
369+
assert t.path == Chain.from_list(
370+
FSSpecChainParser().unchain(str(target))
371+
).active_path.replace("\\", "/")
372+
assert t.exists()
373+
assert t.read_text() == source_text
303374

304375
def test_replace(self):
305376
pass

0 commit comments

Comments
 (0)