Skip to content

Commit 4ff77c7

Browse files
authored
Fix behaviour of UPath.parent and UPath.parents (#529)
* tests: add tests to assert parents start from anchor * tests: data path doesn't have parents so the test is moot * upath.core: fix .parents for UPath base class * upath.implementations.smb: fix .path for root * upath.core and upath.chain: fix chained path handling * upath._flavour: change split to handle anchor correctly * tests: adjust tests for data path * upath._flavour: fix smb flavour parsing * upath.implementations.cloud: fix azure and gcs copying * upath.implementations.cloud: fix hf path parent parsing * upath.core: revert changes to parents with correct flavour.split fix * tests: adjust anchor comparison for windows * tests: skip anchor tests for mock fs on windows
1 parent fb7703d commit 4ff77c7

File tree

8 files changed

+68
-11
lines changed

8 files changed

+68
-11
lines changed

upath/_chain.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,23 +231,23 @@ def unchain(
231231
proto0 is None or bit == proto0
232232
): # exact match a fsspec protocol
233233
proto = bit
234-
path_bit = ""
234+
path_bit = None
235235
extra_so = {}
236236
elif bit in (m := set(available_implementations(fallback=True))) and (
237237
proto0 is None or bit == proto0
238238
):
239239
self.known_protocols = m
240240
proto = bit
241-
path_bit = ""
241+
path_bit = None
242242
extra_so = {}
243243
else:
244244
proto = get_upath_protocol(bit, protocol=proto0)
245245
flavour = WrappedFileSystemFlavour.from_protocol(proto)
246246
path_bit = flavour.strip_protocol(bit)
247247
extra_so = flavour.get_kwargs_from_url(bit)
248248
if proto in {"blockcache", "filecache", "simplecache"}:
249-
if path_bit:
250-
next_path_overwrite = path_bit
249+
if path_bit is not None:
250+
next_path_overwrite = path_bit or "/"
251251
path_bit = None
252252
elif next_path_overwrite is not None:
253253
path_bit = next_path_overwrite

upath/_flavour.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ class WrappedFileSystemFlavour(UPathParser): # (pathlib_abc.FlavourBase)
132132
"https",
133133
},
134134
"root_marker_override": {
135+
"smb": "/",
135136
"ssh": "/",
136137
"sftp": "/",
137138
},
@@ -253,7 +254,7 @@ def stringify_path(pth: JoinablePathLike) -> str:
253254

254255
def strip_protocol(self, pth: JoinablePathLike) -> str:
255256
pth = self.stringify_path(pth)
256-
return self._spec._strip_protocol(pth)
257+
return self._spec._strip_protocol(pth) or self.root_marker
257258

258259
def get_kwargs_from_url(self, url: JoinablePathLike) -> dict[str, Any]:
259260
# NOTE: the public variant is _from_url not _from_urls
@@ -317,6 +318,9 @@ def split(self, path: JoinablePathLike) -> tuple[str, str]:
317318
tail = stripped_path[1:]
318319
elif head:
319320
tail = stripped_path[len(head) + 1 :]
321+
elif self.netloc_is_anchor: # and not head
322+
head = stripped_path
323+
tail = ""
320324
else:
321325
tail = stripped_path
322326
if (

upath/core.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ def __init__(
573573
# FIXME: normalization needs to happen in unchain already...
574574
chain = Chain.from_list(Chain.from_list(segments).to_list())
575575
if len(args) > 1:
576-
flavour = WrappedFileSystemFlavour.from_protocol(protocol)
576+
flavour = WrappedFileSystemFlavour.from_protocol(chain.active_path_protocol)
577577
joined = flavour.join(chain.active_path, *args[1:])
578578
stripped = flavour.strip_protocol(joined)
579579
chain = chain.replace(path=stripped)
@@ -963,7 +963,7 @@ def with_segments(self, *pathsegments: JoinablePathLike) -> Self:
963963
new_instance = type(self)(
964964
*pathsegments,
965965
protocol=self._protocol,
966-
**self._storage_options,
966+
**self.storage_options,
967967
)
968968
if hasattr(self, "_fs_cached"):
969969
new_instance._fs_cached = self._fs_cached
@@ -1090,7 +1090,7 @@ def parent(self) -> Self:
10901090
self._relative_base,
10911091
str(self),
10921092
protocol=self._protocol,
1093-
**self._storage_options,
1093+
**self.storage_options,
10941094
)
10951095
parent = pth.parent
10961096
parent._relative_base = self._relative_base
@@ -1121,7 +1121,7 @@ def parents(self) -> Sequence[Self]:
11211121
break
11221122
parent = parent.parent
11231123
parents.append(parent)
1124-
return parents
1124+
return tuple(parents)
11251125
return super().parents
11261126

11271127
def joinpath(self, *pathsegments: JoinablePathLike) -> Self:
@@ -1945,14 +1945,14 @@ def __reduce__(self):
19451945
args = (self.__vfspath__(),)
19461946
kwargs = {
19471947
"protocol": self._protocol,
1948-
**self._storage_options,
1948+
**self.storage_options,
19491949
}
19501950
else:
19511951
args = (self._relative_base, self.__vfspath__())
19521952
# Include _relative_base in the state if it's set
19531953
kwargs = {
19541954
"protocol": self._protocol,
1955-
**self._storage_options,
1955+
**self.storage_options,
19561956
"_relative_base": self._relative_base,
19571957
}
19581958
return _make_instance, (type(self), args, kwargs)

upath/implementations/cloud.py

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

33
import sys
44
from collections.abc import Iterator
5+
from collections.abc import Sequence
56
from typing import TYPE_CHECKING
67
from typing import Any
78

@@ -82,6 +83,13 @@ def path(self) -> str:
8283
return self_path + self.root
8384
return self_path
8485

86+
@property
87+
def parts(self) -> Sequence[str]:
88+
parts = super().parts
89+
if self._relative_base is None and len(parts) == 2 and not parts[1]:
90+
return parts[:1]
91+
return parts
92+
8593
def mkdir(
8694
self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False
8795
) -> None:
@@ -171,6 +179,10 @@ def __init__(
171179
*args, protocol=protocol, chain_parser=chain_parser, **storage_options
172180
)
173181

182+
@property
183+
def root(self) -> str:
184+
return ""
185+
174186
def iterdir(self) -> Iterator[Self]:
175187
try:
176188
yield from super().iterdir()

upath/implementations/smb.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def path(self) -> str:
4242
path = super().path
4343
if len(path) > 1:
4444
return path.removesuffix("/")
45+
# At root level, return "/" to match anchor
46+
if not path and self._relative_base is None:
47+
return self.anchor
4548
return path
4649

4750
def __str__(self) -> str:

upath/tests/cases.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from upath import UPath
1818
from upath._protocol import get_upath_protocol
1919
from upath._stat import UPathStatResult
20+
from upath.tests.utils import posixify
2021
from upath.types import StatResultType
2122

2223

@@ -248,6 +249,16 @@ def test_parents_are_absolute(self):
248249
is_absolute = [p.is_absolute() for p in self.path.parents]
249250
assert all(is_absolute)
250251

252+
def test_parents_end_at_anchor(self):
253+
p = self.path.joinpath("folder1", "file1.txt")
254+
assert p.parents[-1].path == posixify(p.anchor)
255+
256+
def test_anchor_is_its_own_parent(self):
257+
p = self.path.joinpath("folder1", "file1.txt")
258+
p0 = p.parents[-1]
259+
assert p0.path == posixify(p.anchor)
260+
assert p0.parent.path == posixify(p.anchor)
261+
251262
def test_private_url_attr_in_sync(self):
252263
p = self.path
253264
p1 = self.path.joinpath("c")

upath/tests/implementations/test_data.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,17 @@ def test_trailing_slash_is_stripped(self):
129129
with pytest.raises(UnsupportedOperation):
130130
super().test_trailing_slash_is_stripped()
131131

132+
@overrides_base
133+
def test_parents_end_at_anchor(self):
134+
# DataPath does not support joins
135+
with pytest.raises(UnsupportedOperation):
136+
super().test_parents_end_at_anchor()
137+
138+
@overrides_base
139+
def test_anchor_is_its_own_parent(self):
140+
# DataPath does not support joins
141+
assert self.path.path == self.path.parent.path
142+
132143
@overrides_base
133144
def test_private_url_attr_in_sync(self):
134145
# DataPath does not support joins, so we check on self.path

upath/tests/test_core.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,22 @@ def test_is_correct_class(self):
7272
def test_parents_are_absolute(self):
7373
super().test_parents_are_absolute()
7474

75+
@overrides_base
76+
@pytest.mark.skipif(
77+
sys.platform.startswith("win"),
78+
reason="mock fs is not well defined on windows",
79+
)
80+
def test_anchor_is_its_own_parent(self):
81+
super().test_anchor_is_its_own_parent()
82+
83+
@overrides_base
84+
@pytest.mark.skipif(
85+
sys.platform.startswith("win"),
86+
reason="mock fs is not well defined on windows",
87+
)
88+
def test_parents_end_at_anchor(self):
89+
super().test_parents_end_at_anchor()
90+
7591

7692
def test_multiple_backend_paths(local_testdir):
7793
path = "s3://bucket/"

0 commit comments

Comments
 (0)