From b884da0d92ded773e56e88e6c13d5664420a2920 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Sun, 4 Jan 2026 19:10:04 +0100 Subject: [PATCH 01/13] tests: add tests to assert parents start from anchor --- upath/tests/cases.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/upath/tests/cases.py b/upath/tests/cases.py index 24485e69..5f28010c 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -248,6 +248,10 @@ def test_parents_are_absolute(self): is_absolute = [p.is_absolute() for p in self.path.parents] assert all(is_absolute) + def tests_parents_end_at_anchor(self): + p = self.path.joinpath("folder1", "file1.txt") + assert p.parents[-1].path == p.anchor + def test_private_url_attr_in_sync(self): p = self.path p1 = self.path.joinpath("c") From 759f8a7a710c17d7724402585575efcbe11f18b7 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Sun, 4 Jan 2026 19:12:04 +0100 Subject: [PATCH 02/13] tests: data path doesn't have parents so the test is moot --- upath/tests/implementations/test_data.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/upath/tests/implementations/test_data.py b/upath/tests/implementations/test_data.py index b539e30e..090b3a8b 100644 --- a/upath/tests/implementations/test_data.py +++ b/upath/tests/implementations/test_data.py @@ -129,6 +129,12 @@ def test_trailing_slash_is_stripped(self): with pytest.raises(UnsupportedOperation): super().test_trailing_slash_is_stripped() + @overrides_base + def tests_parents_end_at_anchor(self): + # DataPath does not support joins + with pytest.raises(UnsupportedOperation): + super().tests_parents_end_at_anchor() + @overrides_base def test_private_url_attr_in_sync(self): # DataPath does not support joins, so we check on self.path From 58e897bf5f9278913f63cbe2dd1b3b6453852c65 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Sun, 4 Jan 2026 19:22:59 +0100 Subject: [PATCH 03/13] upath.core: fix .parents for UPath base class --- upath/core.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/upath/core.py b/upath/core.py index 90ac7fef..19f7cb23 100644 --- a/upath/core.py +++ b/upath/core.py @@ -1121,8 +1121,19 @@ def parents(self) -> Sequence[Self]: break parent = parent.parent parents.append(parent) - return parents - return super().parents + return tuple(parents) + else: + # todo: this is not the correct fix. The actual + # fix would be to correctly implement split + # for all URI flavours to return the anchor + # as the head of the (head, tail) tuple. + parents = [] + anchor = self.anchor + for p in super().parents: + parents.append(p) + if p.path == anchor: + break + return tuple(parents) def joinpath(self, *pathsegments: JoinablePathLike) -> Self: """Combine this path with one or several arguments, and return a new path. From b68b1cc23c5a65b42eb5429f1c1866d89beb6707 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Sun, 4 Jan 2026 19:24:21 +0100 Subject: [PATCH 04/13] upath.implementations.smb: fix .path for root --- upath/implementations/smb.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/upath/implementations/smb.py b/upath/implementations/smb.py index c967e370..956fdbad 100644 --- a/upath/implementations/smb.py +++ b/upath/implementations/smb.py @@ -42,6 +42,9 @@ def path(self) -> str: path = super().path if len(path) > 1: return path.removesuffix("/") + # At root level, return "/" to match anchor + if not path and self._relative_base is None: + return self.anchor return path def __str__(self) -> str: From b8682be46aca68155a080598062f48fda1782259 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Sun, 4 Jan 2026 22:56:33 +0100 Subject: [PATCH 05/13] upath.core and upath.chain: fix chained path handling --- upath/_chain.py | 8 ++++---- upath/_flavour.py | 2 +- upath/core.py | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/upath/_chain.py b/upath/_chain.py index 83d3db5a..23a25741 100644 --- a/upath/_chain.py +++ b/upath/_chain.py @@ -231,14 +231,14 @@ def unchain( proto0 is None or bit == proto0 ): # exact match a fsspec protocol proto = bit - path_bit = "" + path_bit = None extra_so = {} elif bit in (m := set(available_implementations(fallback=True))) and ( proto0 is None or bit == proto0 ): self.known_protocols = m proto = bit - path_bit = "" + path_bit = None extra_so = {} else: proto = get_upath_protocol(bit, protocol=proto0) @@ -246,8 +246,8 @@ def unchain( path_bit = flavour.strip_protocol(bit) extra_so = flavour.get_kwargs_from_url(bit) if proto in {"blockcache", "filecache", "simplecache"}: - if path_bit: - next_path_overwrite = path_bit + if path_bit is not None: + next_path_overwrite = path_bit or "/" path_bit = None elif next_path_overwrite is not None: path_bit = next_path_overwrite diff --git a/upath/_flavour.py b/upath/_flavour.py index 3274bcb2..e27e0f18 100644 --- a/upath/_flavour.py +++ b/upath/_flavour.py @@ -253,7 +253,7 @@ def stringify_path(pth: JoinablePathLike) -> str: def strip_protocol(self, pth: JoinablePathLike) -> str: pth = self.stringify_path(pth) - return self._spec._strip_protocol(pth) + return self._spec._strip_protocol(pth) or self.root_marker def get_kwargs_from_url(self, url: JoinablePathLike) -> dict[str, Any]: # NOTE: the public variant is _from_url not _from_urls diff --git a/upath/core.py b/upath/core.py index 19f7cb23..c6a6c880 100644 --- a/upath/core.py +++ b/upath/core.py @@ -573,7 +573,7 @@ def __init__( # FIXME: normalization needs to happen in unchain already... chain = Chain.from_list(Chain.from_list(segments).to_list()) if len(args) > 1: - flavour = WrappedFileSystemFlavour.from_protocol(protocol) + flavour = WrappedFileSystemFlavour.from_protocol(chain.active_path_protocol) joined = flavour.join(chain.active_path, *args[1:]) stripped = flavour.strip_protocol(joined) chain = chain.replace(path=stripped) @@ -963,7 +963,7 @@ def with_segments(self, *pathsegments: JoinablePathLike) -> Self: new_instance = type(self)( *pathsegments, protocol=self._protocol, - **self._storage_options, + **self.storage_options, ) if hasattr(self, "_fs_cached"): new_instance._fs_cached = self._fs_cached @@ -1090,7 +1090,7 @@ def parent(self) -> Self: self._relative_base, str(self), protocol=self._protocol, - **self._storage_options, + **self.storage_options, ) parent = pth.parent parent._relative_base = self._relative_base @@ -1956,14 +1956,14 @@ def __reduce__(self): args = (self.__vfspath__(),) kwargs = { "protocol": self._protocol, - **self._storage_options, + **self.storage_options, } else: args = (self._relative_base, self.__vfspath__()) # Include _relative_base in the state if it's set kwargs = { "protocol": self._protocol, - **self._storage_options, + **self.storage_options, "_relative_base": self._relative_base, } return _make_instance, (type(self), args, kwargs) From 71a94ff8f929f11058ea4e350b67e4060b4e245a Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Sun, 4 Jan 2026 23:33:30 +0100 Subject: [PATCH 06/13] upath._flavour: change split to handle anchor correctly --- upath/_flavour.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/upath/_flavour.py b/upath/_flavour.py index e27e0f18..d7e79d01 100644 --- a/upath/_flavour.py +++ b/upath/_flavour.py @@ -317,6 +317,9 @@ def split(self, path: JoinablePathLike) -> tuple[str, str]: tail = stripped_path[1:] elif head: tail = stripped_path[len(head) + 1 :] + elif self.netloc_is_anchor: # and not head + head = stripped_path + tail = "" else: tail = stripped_path if ( From a8a61641aefc317e18bcec24036cb67d6a55d15f Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Sun, 4 Jan 2026 23:41:57 +0100 Subject: [PATCH 07/13] tests: adjust tests for data path --- upath/tests/cases.py | 8 +++++++- upath/tests/implementations/test_data.py | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/upath/tests/cases.py b/upath/tests/cases.py index 5f28010c..6853abab 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -248,10 +248,16 @@ def test_parents_are_absolute(self): is_absolute = [p.is_absolute() for p in self.path.parents] assert all(is_absolute) - def tests_parents_end_at_anchor(self): + def test_parents_end_at_anchor(self): p = self.path.joinpath("folder1", "file1.txt") assert p.parents[-1].path == p.anchor + def test_anchor_is_its_own_parent(self): + p = self.path.joinpath("folder1", "file1.txt") + p0 = p.parents[-1] + assert p0.path == p.anchor + assert p0.parent.path == p.anchor + def test_private_url_attr_in_sync(self): p = self.path p1 = self.path.joinpath("c") diff --git a/upath/tests/implementations/test_data.py b/upath/tests/implementations/test_data.py index 090b3a8b..a9e2f4b8 100644 --- a/upath/tests/implementations/test_data.py +++ b/upath/tests/implementations/test_data.py @@ -130,10 +130,15 @@ def test_trailing_slash_is_stripped(self): super().test_trailing_slash_is_stripped() @overrides_base - def tests_parents_end_at_anchor(self): + def test_parents_end_at_anchor(self): # DataPath does not support joins with pytest.raises(UnsupportedOperation): - super().tests_parents_end_at_anchor() + super().test_parents_end_at_anchor() + + @overrides_base + def test_anchor_is_its_own_parent(self): + # DataPath does not support joins + assert self.path.path == self.path.parent.path @overrides_base def test_private_url_attr_in_sync(self): From 4735d79795cba97ad726a19272cbbf2fc293a47b Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 00:21:30 +0100 Subject: [PATCH 08/13] upath._flavour: fix smb flavour parsing --- upath/_flavour.py | 1 + 1 file changed, 1 insertion(+) diff --git a/upath/_flavour.py b/upath/_flavour.py index d7e79d01..842a8350 100644 --- a/upath/_flavour.py +++ b/upath/_flavour.py @@ -132,6 +132,7 @@ class WrappedFileSystemFlavour(UPathParser): # (pathlib_abc.FlavourBase) "https", }, "root_marker_override": { + "smb": "/", "ssh": "/", "sftp": "/", }, From 1ef186c3425df5129d61a91c25ecf462b3bec7c6 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 00:22:25 +0100 Subject: [PATCH 09/13] upath.implementations.cloud: fix azure and gcs copying --- upath/implementations/cloud.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/upath/implementations/cloud.py b/upath/implementations/cloud.py index 02c89db3..6253fa7e 100644 --- a/upath/implementations/cloud.py +++ b/upath/implementations/cloud.py @@ -2,6 +2,7 @@ import sys from collections.abc import Iterator +from collections.abc import Sequence from typing import TYPE_CHECKING from typing import Any @@ -82,6 +83,13 @@ def path(self) -> str: return self_path + self.root return self_path + @property + def parts(self) -> Sequence[str]: + parts = super().parts + if self._relative_base is None and len(parts) == 2 and not parts[1]: + return parts[:1] + return parts + def mkdir( self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False ) -> None: From 859117307f113aa380b311d5c2ba9c52de693dea Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 00:22:49 +0100 Subject: [PATCH 10/13] upath.implementations.cloud: fix hf path parent parsing --- upath/implementations/cloud.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/upath/implementations/cloud.py b/upath/implementations/cloud.py index 6253fa7e..7814a456 100644 --- a/upath/implementations/cloud.py +++ b/upath/implementations/cloud.py @@ -179,6 +179,10 @@ def __init__( *args, protocol=protocol, chain_parser=chain_parser, **storage_options ) + @property + def root(self) -> str: + return "" + def iterdir(self) -> Iterator[Self]: try: yield from super().iterdir() From 5abe3b2da9eca8f58ec2846bf645861be4042f01 Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 00:33:31 +0100 Subject: [PATCH 11/13] upath.core: revert changes to parents with correct flavour.split fix --- upath/core.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/upath/core.py b/upath/core.py index c6a6c880..7c28ba00 100644 --- a/upath/core.py +++ b/upath/core.py @@ -1122,18 +1122,7 @@ def parents(self) -> Sequence[Self]: parent = parent.parent parents.append(parent) return tuple(parents) - else: - # todo: this is not the correct fix. The actual - # fix would be to correctly implement split - # for all URI flavours to return the anchor - # as the head of the (head, tail) tuple. - parents = [] - anchor = self.anchor - for p in super().parents: - parents.append(p) - if p.path == anchor: - break - return tuple(parents) + return super().parents def joinpath(self, *pathsegments: JoinablePathLike) -> Self: """Combine this path with one or several arguments, and return a new path. From 8151d8dd3e45f4bfee7342b7d9aa885948c02fae Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 00:46:28 +0100 Subject: [PATCH 12/13] tests: adjust anchor comparison for windows --- upath/tests/cases.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/upath/tests/cases.py b/upath/tests/cases.py index 6853abab..dddb9852 100644 --- a/upath/tests/cases.py +++ b/upath/tests/cases.py @@ -17,6 +17,7 @@ from upath import UPath from upath._protocol import get_upath_protocol from upath._stat import UPathStatResult +from upath.tests.utils import posixify from upath.types import StatResultType @@ -250,13 +251,13 @@ def test_parents_are_absolute(self): def test_parents_end_at_anchor(self): p = self.path.joinpath("folder1", "file1.txt") - assert p.parents[-1].path == p.anchor + assert p.parents[-1].path == posixify(p.anchor) def test_anchor_is_its_own_parent(self): p = self.path.joinpath("folder1", "file1.txt") p0 = p.parents[-1] - assert p0.path == p.anchor - assert p0.parent.path == p.anchor + assert p0.path == posixify(p.anchor) + assert p0.parent.path == posixify(p.anchor) def test_private_url_attr_in_sync(self): p = self.path From bdbdb2aa5910fd5243e9e9e7895d5429c3b14a6c Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Mon, 5 Jan 2026 00:59:00 +0100 Subject: [PATCH 13/13] tests: skip anchor tests for mock fs on windows --- upath/tests/test_core.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/upath/tests/test_core.py b/upath/tests/test_core.py index 822cc8c7..9d6d9317 100644 --- a/upath/tests/test_core.py +++ b/upath/tests/test_core.py @@ -72,6 +72,22 @@ def test_is_correct_class(self): def test_parents_are_absolute(self): super().test_parents_are_absolute() + @overrides_base + @pytest.mark.skipif( + sys.platform.startswith("win"), + reason="mock fs is not well defined on windows", + ) + def test_anchor_is_its_own_parent(self): + super().test_anchor_is_its_own_parent() + + @overrides_base + @pytest.mark.skipif( + sys.platform.startswith("win"), + reason="mock fs is not well defined on windows", + ) + def test_parents_end_at_anchor(self): + super().test_parents_end_at_anchor() + def test_multiple_backend_paths(local_testdir): path = "s3://bucket/"