From 4f17e39931de71da7b77891834b3d5ef78c034e0 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 30 Jan 2024 13:54:09 +0100
Subject: [PATCH 01/50] (wip) customizable fsspec filesystem factory

---
 upath/core.py        | 30 ++++++++++++++++++++++++
 upath/core312plus.py | 56 +++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 83 insertions(+), 3 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index 8ba8078f..ce8034ce 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -7,6 +7,7 @@
 from pathlib import PurePath
 from pathlib import _PosixFlavour  # type: ignore
 from typing import TYPE_CHECKING
+from typing import Mapping
 from typing import Sequence
 from typing import TypeVar
 from urllib.parse import urlsplit
@@ -244,6 +245,35 @@ def storage_options(self) -> dict[str, Any]:
             if key not in {"scheme", "netloc", "url"}
         }
 
+    def __init_subclass__(cls, **kwargs):
+        """provide a clean migration path for custom user subclasses"""
+
+        has_custom_fs_factory = cls._fs_factory is not UPath._fs_factory
+
+        if has_custom_fs_factory:
+            user_fs_factory = cls._fs_factory
+
+            def _default_accessor(
+                parsed_url: SplitResult | None, **kwargs: Any
+            ) -> _FSSpecAccessor:
+                accessor = object.__new__(cls._default_accessor)
+                accessor._fs = user_fs_factory(
+                    parsed_url.geturl(), protocol=parsed_url.scheme or "", **kwargs
+                )
+                return accessor
+
+            cls._default_accessor = _default_accessor
+
+    @classmethod
+    def _fs_factory(
+        cls, urlpath: str, protocol: str | None, storage_options: Mapping[str, Any]
+    ) -> AbstractFileSystem:
+        """Instantiate the filesystem_spec filesystem class"""
+        fs_cls = get_filesystem_class(protocol)
+        so_dct = fs_cls._get_kwargs_from_urls(urlpath)
+        so_dct.update(storage_options)
+        return fs_cls(**storage_options)
+
     @property
     def fs(self) -> AbstractFileSystem:
         """The filesystem_spec filesystem instance"""
diff --git a/upath/core312plus.py b/upath/core312plus.py
index dae68419..4d3b4912 100644
--- a/upath/core312plus.py
+++ b/upath/core312plus.py
@@ -14,6 +14,7 @@
 from typing import Mapping
 from typing import TypeAlias
 from typing import cast
+from urllib.parse import SplitResult
 from urllib.parse import urlsplit
 
 if sys.version_info >= (3, 11):
@@ -22,7 +23,6 @@
     Self = Any
 
 from fsspec import AbstractFileSystem
-from fsspec import filesystem
 from fsspec import get_filesystem_class
 from fsspec.core import strip_protocol as fsspec_strip_protocol
 
@@ -34,6 +34,9 @@
 class _FSSpecAccessor:
     """this is a compatibility shim and will be removed"""
 
+    def __init__(self, parsed_url: SplitResult | None, **kwargs: Any) -> None:
+        pass
+
 
 class FSSpecFlavour:
     """fsspec flavour for universal_pathlib
@@ -370,13 +373,60 @@ def protocol(self) -> str:
     def storage_options(self) -> Mapping[str, Any]:
         return MappingProxyType(self._storage_options)
 
+    def __init_subclass__(cls, **kwargs):
+        """provide a clean migration path for custom user subclasses"""
+
+        accessor_cls = getattr(cls, "_default_accessor", None)
+
+        # guess which parts the user subclass is customizing
+        has_custom_legacy_accessor = (
+            accessor_cls is not None
+            and issubclass(accessor_cls, _FSSpecAccessor)
+            and accessor_cls is not _FSSpecAccessor
+        )
+        has_customized_fs_instantiation = (
+            accessor_cls.__init__ is not _FSSpecAccessor.__init__
+            or hasattr(accessor_cls, "_fs")
+        )
+
+        if has_custom_legacy_accessor and has_customized_fs_instantiation:
+            warnings.warn(
+                "Detected a customized `__init__` method or `_fs` attribute"
+                " in the provided `_FSSpecAccessor` subclass. It is recommended to"
+                " instead override the `_fs_factory` classmethod to customize"
+                " filesystem instantiation.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+
+            def _fs_factory(
+                cls_, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
+            ) -> AbstractFileSystem:
+                url = urlsplit(urlpath)
+                if protocol:
+                    url = url._replace(scheme=protocol)
+                inst = cls_._default_accessor(url, **storage_options)
+                return inst._fs
+
+            cls._fs_factory = classmethod(_fs_factory)
+
+    @classmethod
+    def _fs_factory(
+        cls, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
+    ) -> AbstractFileSystem:
+        """Instantiate the filesystem_spec filesystem class"""
+        fs_cls = get_filesystem_class(protocol)
+        so_dct = fs_cls._get_kwargs_from_urls(urlpath)
+        so_dct.update(storage_options)
+        return fs_cls(**storage_options)
+
     @property
     def fs(self) -> AbstractFileSystem:
         try:
             return self._fs_cached
         except AttributeError:
-            fs = self._fs_cached = filesystem(
-                protocol=self.protocol, **self.storage_options
+            fs = self._fs_cached = self._fs_factory(
+                str(self), self.protocol, self.storage_options
             )
             return fs
 

From 6bb561af1ff9784a122e23985613774460a995d0 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 30 Jan 2024 14:01:56 +0100
Subject: [PATCH 02/50] upath.core: fix _default_accessor override error

---
 upath/core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/upath/core.py b/upath/core.py
index ce8034ce..05404e74 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -262,7 +262,7 @@ def _default_accessor(
                 )
                 return accessor
 
-            cls._default_accessor = _default_accessor
+            cls._default_accessor = staticmethod(_default_accessor)
 
     @classmethod
     def _fs_factory(

From 413e01184c2275a0ddc1dc7df02721202ce736c4 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 30 Jan 2024 14:08:28 +0100
Subject: [PATCH 03/50] upath.core: fix _fs_factory call

---
 upath/core.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index 05404e74..cbf55603 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -252,13 +252,14 @@ def __init_subclass__(cls, **kwargs):
 
         if has_custom_fs_factory:
             user_fs_factory = cls._fs_factory
+            user_accessor = cls._default_accessor
 
             def _default_accessor(
                 parsed_url: SplitResult | None, **kwargs: Any
             ) -> _FSSpecAccessor:
-                accessor = object.__new__(cls._default_accessor)
+                accessor = object.__new__(user_accessor)
                 accessor._fs = user_fs_factory(
-                    parsed_url.geturl(), protocol=parsed_url.scheme or "", **kwargs
+                    parsed_url.geturl(), parsed_url.scheme or "", kwargs
                 )
                 return accessor
 

From dea4aebfb432f7d2b18d7d4c1c51b7e14ed35830 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 30 Jan 2024 14:55:52 +0100
Subject: [PATCH 04/50] upath.core: create a patched subclass for _fs_factory

---
 upath/core.py | 49 +++++++++++++++++++++++++++++++++++--------------
 1 file changed, 35 insertions(+), 14 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index cbf55603..e722e438 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -7,6 +7,7 @@
 from pathlib import PurePath
 from pathlib import _PosixFlavour  # type: ignore
 from typing import TYPE_CHECKING
+from typing import Callable
 from typing import Mapping
 from typing import Sequence
 from typing import TypeVar
@@ -114,6 +115,31 @@ def mv(self, path, target, recursive=False, maxdepth=None, **kwargs):
         )
 
 
+def _patch_accessor(
+    accessor_cls: type[_FSSpecAccessor],
+    fs_factory: Callable[[str, str, Mapping[str, Any]], AbstractFileSystem],
+) -> Callable[..., _FSSpecAccessor]:
+    """Patch the _FSSpecAccessor class to support `UPath._fs_factory` overrides"""
+
+    def _patch_init(
+        self,
+        parsed_url: SplitResult | None,
+        **kwargs: Any,
+    ) -> None:
+        self._fs = self._fs_factory(
+            parsed_url.geturl(), parsed_url.scheme or "", kwargs
+        )
+
+    return type(
+        f"Patched{accessor_cls.__name__}",
+        (accessor_cls,),
+        {
+            "__init__": _patch_init,
+            "_fs_factory": staticmethod(fs_factory),
+        },
+    )
+
+
 class _UriFlavour(_PosixFlavour):
     def parse_parts(self, parts):
         parsed = []
@@ -249,21 +275,16 @@ def __init_subclass__(cls, **kwargs):
         """provide a clean migration path for custom user subclasses"""
 
         has_custom_fs_factory = cls._fs_factory is not UPath._fs_factory
+        is_patched = (
+            hasattr(cls._default_accessor, "_fs_factory")
+            and cls._default_accessor._fs_factory is not cls._fs_factory
+        )
 
-        if has_custom_fs_factory:
-            user_fs_factory = cls._fs_factory
-            user_accessor = cls._default_accessor
-
-            def _default_accessor(
-                parsed_url: SplitResult | None, **kwargs: Any
-            ) -> _FSSpecAccessor:
-                accessor = object.__new__(user_accessor)
-                accessor._fs = user_fs_factory(
-                    parsed_url.geturl(), parsed_url.scheme or "", kwargs
-                )
-                return accessor
-
-            cls._default_accessor = staticmethod(_default_accessor)
+        if has_custom_fs_factory and not is_patched:
+            cls._default_accessor = _patch_accessor(
+                accessor_cls=cls._default_accessor,
+                fs_factory=cls._fs_factory,
+            )
 
     @classmethod
     def _fs_factory(

From 780e9d814cac47f29e7e9c3720920755724a5320 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 30 Jan 2024 15:09:53 +0100
Subject: [PATCH 05/50] upath.core: fix has_custom_fs_factory check

---
 upath/core.py | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index e722e438..92fef025 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -274,10 +274,13 @@ def storage_options(self) -> dict[str, Any]:
     def __init_subclass__(cls, **kwargs):
         """provide a clean migration path for custom user subclasses"""
 
-        has_custom_fs_factory = cls._fs_factory is not UPath._fs_factory
+        has_custom_fs_factory = (
+            cls._fs_factory.__func__ is not UPath._fs_factory.__func__
+        )
         is_patched = (
             hasattr(cls._default_accessor, "_fs_factory")
-            and cls._default_accessor._fs_factory is not cls._fs_factory
+            and cls._default_accessor._fs_factory.__func__
+            is not cls._fs_factory.__func__
         )
 
         if has_custom_fs_factory and not is_patched:

From a4f2b27251836081c5fb136366ac59f719af1033 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Thu, 1 Feb 2024 09:30:55 +0100
Subject: [PATCH 06/50] upath._protocol: move to upath protocol parsing

---
 upath/_protocol.py   | 63 +++++++++++++++++++++++++++++++++++++++++++
 upath/core.py        |  7 ++---
 upath/core312plus.py | 64 +++++---------------------------------------
 3 files changed, 74 insertions(+), 60 deletions(-)
 create mode 100644 upath/_protocol.py

diff --git a/upath/_protocol.py b/upath/_protocol.py
new file mode 100644
index 00000000..e11407a9
--- /dev/null
+++ b/upath/_protocol.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import os
+import re
+from pathlib import PurePath
+from typing import Any
+
+from fsspec.core import strip_protocol as strip_fsspec_protocol
+
+__all__ = [
+    "get_upath_protocol",
+    "strip_upath_protocol",
+]
+
+# Regular expression to match fsspec style protocols. Matches single
+# slash usage too.
+_PROTOCOL_RE = re.compile(
+    r"^(?P<protocol>[A-Za-z][A-Za-z0-9+]+):(?P<slashes>//?)(?P<path>.*)"
+)
+
+
+def _match_protocol(pth: str) -> str:
+    if m := _PROTOCOL_RE.match(pth):
+        return m.group("protocol")
+    return ""
+
+
+def get_upath_protocol(
+    pth: str | PurePath | os.PathLike,
+    *,
+    protocol: str | None = None,
+    storage_options: dict[str, Any] | None = None,
+) -> str:
+    """return the filesystem spec protocol"""
+    if isinstance(pth, str):
+        pth_protocol = _match_protocol(pth)
+    elif isinstance(pth, PurePath):
+        pth_protocol = getattr(pth, "protocol", "")
+    else:
+        pth_protocol = _match_protocol(str(pth))
+    if storage_options and not protocol and not pth_protocol:
+        protocol = "file"
+    if protocol and pth_protocol and not pth_protocol.startswith(protocol):
+        raise ValueError(
+            f"requested protocol {protocol!r} incompatible with {pth_protocol!r}"
+        )
+    return protocol or pth_protocol or ""
+
+
+def strip_upath_protocol(pth: str | os.PathLike[str]) -> str:
+    """strip protocol from path"""
+    if isinstance(pth, PurePath):
+        pth = str(pth)
+    elif not isinstance(pth, str):
+        pth = os.fspath(pth)
+    if m := _PROTOCOL_RE.match(pth):
+        protocol = m.group("protocol")
+        path = m.group("path")
+        if len(m.group("slashes")) == 1:
+            pth = f"{protocol}:///{path}"
+        return strip_fsspec_protocol(pth)
+    else:
+        return pth
diff --git a/upath/core.py b/upath/core.py
index 92fef025..e487fe04 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -14,10 +14,11 @@
 from urllib.parse import urlsplit
 from urllib.parse import urlunsplit
 
-from fsspec.core import split_protocol
 from fsspec.registry import get_filesystem_class
 from fsspec.utils import stringify_path
 
+from upath._protocol import get_upath_protocol
+from upath._protocol import strip_upath_protocol
 from upath.registry import get_upath_class
 
 if TYPE_CHECKING:
@@ -217,7 +218,7 @@ def __new__(cls: type[PT], *args: str | PathLike, **kwargs: Any) -> PT:
             )
 
         url = stringify_path(other)
-        protocol, _ = split_protocol(url)
+        protocol = get_upath_protocol(url)
         parsed_url = urlsplit(url)
 
         if protocol is None and ":/" in url[2:]:  # excludes windows paths: C:/...
@@ -240,7 +241,7 @@ def __new__(cls: type[PT], *args: str | PathLike, **kwargs: Any) -> PT:
         if not protocol:
             args_list.insert(0, url)
         else:
-            args_list.insert(0, parsed_url.path)
+            args_list.insert(0, strip_upath_protocol(url))
 
         return upath_cls._from_parts(  # type: ignore
             args_list, url=parsed_url, **kwargs
diff --git a/upath/core312plus.py b/upath/core312plus.py
index 4d3b4912..216ab530 100644
--- a/upath/core312plus.py
+++ b/upath/core312plus.py
@@ -2,7 +2,6 @@
 
 import os
 import posixpath
-import re
 import sys
 import warnings
 from copy import copy
@@ -24,8 +23,9 @@
 
 from fsspec import AbstractFileSystem
 from fsspec import get_filesystem_class
-from fsspec.core import strip_protocol as fsspec_strip_protocol
 
+from upath._protocol import get_upath_protocol
+from upath._protocol import strip_upath_protocol
 from upath.registry import get_upath_class
 
 PathOrStr: TypeAlias = "str | PurePath | os.PathLike"
@@ -117,7 +117,7 @@ def join(self, __path: PathOrStr, *paths: PathOrStr) -> str:
         else:
             joined = os.path.join(path, *paths)
 
-        if self.join_prepends_protocol and (protocol := _match_protocol(__path)):
+        if self.join_prepends_protocol and (protocol := get_upath_protocol(__path)):
             joined = f"{protocol}://{joined}"
 
         return joined
@@ -128,12 +128,13 @@ def splitroot(self, __path: PathOrStr) -> tuple[str, str, str]:
             url = urlsplit(__path)
             drive = url._replace(path="", query="", fragment="").geturl()
             path = url._replace(scheme="", netloc="").geturl()
-            root = "/" if path.startswith("/") else ""
+            # root = "/" if path.startswith("/") else ""
+            root = "/"  # emulate upath.core.UPath < 3.12 behaviour
             return drive, root, path.removeprefix("/")
 
         path = strip_upath_protocol(__path)
         if self.supports_netloc:
-            protocol = _match_protocol(__path)
+            protocol = get_upath_protocol(__path)
             if protocol:
                 drive, root, tail = path.partition("/")
                 return drive, root or "/", tail
@@ -158,7 +159,7 @@ def splitdrive(self, __path: PathOrStr) -> tuple[str, str]:
 
         path = strip_upath_protocol(__path)
         if self.supports_netloc:
-            protocol = _match_protocol(__path)
+            protocol = get_upath_protocol(__path)
             if protocol:
                 drive, root, tail = path.partition("/")
                 return drive, f"{root}{tail}"
@@ -180,57 +181,6 @@ def normcase(self, __path: PathOrStr) -> str:
             return os.path.normcase(__path)
 
 
-_PROTOCOL_RE = re.compile(
-    r"^(?P<protocol>[A-Za-z][A-Za-z0-9+]+):(?P<slashes>//?)(?P<path>.*)"
-)
-
-
-def strip_upath_protocol(pth: PathOrStr) -> str:
-    """strip protocol from path"""
-    if isinstance(pth, PurePath):
-        pth = str(pth)
-    elif not isinstance(pth, str):
-        pth = os.fspath(pth)
-    if m := _PROTOCOL_RE.match(pth):
-        protocol = m.group("protocol")
-        path = m.group("path")
-        if len(m.group("slashes")) == 1:
-            pth = f"{protocol}:///{path}"
-        return fsspec_strip_protocol(pth)
-    else:
-        return pth
-
-
-def _match_protocol(pth: str) -> str:
-    if m := _PROTOCOL_RE.match(pth):
-        return m.group("protocol")
-    return ""
-
-
-def get_upath_protocol(
-    pth: str | PurePath | os.PathLike,
-    *,
-    protocol: str | None = None,
-    storage_options: dict[str, Any] | None = None,
-) -> str:
-    """return the filesystem spec protocol"""
-    if isinstance(pth, str):
-        pth_protocol = _match_protocol(pth)
-    elif isinstance(pth, UPath):
-        pth_protocol = pth.protocol
-    elif isinstance(pth, PurePath):
-        pth_protocol = ""
-    else:
-        pth_protocol = _match_protocol(os.fspath(pth))
-    if storage_options and not protocol and not pth_protocol:
-        protocol = "file"
-    if protocol and pth_protocol and not pth_protocol.startswith(protocol):
-        raise ValueError(
-            f"requested protocol {protocol!r} incompatible with {pth_protocol!r}"
-        )
-    return protocol or pth_protocol or ""
-
-
 def _make_instance(cls, args, kwargs):
     """helper for pickling UPath instances"""
     return cls(*args, **kwargs)

From c6d600f3497bad041405baba91af087b9390101c Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Thu, 1 Feb 2024 09:47:06 +0100
Subject: [PATCH 07/50] tests: allow setting azurite port through envvar

---
 upath/tests/conftest.py | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/upath/tests/conftest.py b/upath/tests/conftest.py
index 04d2b27a..a2f85b0f 100644
--- a/upath/tests/conftest.py
+++ b/upath/tests/conftest.py
@@ -327,9 +327,12 @@ def webdav_fixture(local_testdir, webdav_server):
         fs_provider.lock_manager.storage.clear()
 
 
+AZURITE_PORT = int(os.environ.get("UPATH_AZURITE_PORT", "10000"))
+
+
 @pytest.fixture(scope="session")
 def azurite_credentials():
-    url = "http://localhost:10000"
+    url = f"http://localhost:{AZURITE_PORT}"
     account_name = "devstoreaccount1"
     key = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="  # noqa: E501
     endpoint = f"{url}/{account_name}"
@@ -348,10 +351,10 @@ def docker_azurite(azurite_credentials):
     image = "mcr.microsoft.com/azure-storage/azurite"
     container_name = "azure_test"
     cmd = (
-        f"docker run --rm -d -p 10000:10000 --name {container_name} {image}"  # noqa: E501
+        f"docker run --rm -d -p {AZURITE_PORT}:10000 --name {container_name} {image}"  # noqa: E501
         " azurite-blob --loose --blobHost 0.0.0.0"  # noqa: E501
     )
-    url = "http://localhost:10000"
+    url = f"http://localhost:{AZURITE_PORT}"
 
     stop_docker(container_name)
     subprocess.run(shlex.split(cmd), check=True)

From fb1cc83ff37cb1f80b6f179ce55cc13fe067c215 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Fri, 2 Feb 2024 00:47:10 +0100
Subject: [PATCH 08/50] upath._flavour: factor out FSSpecFlavour

---
 upath/_flavour.py    | 265 +++++++++++++++++++++++++++++++++++++++++++
 upath/core312plus.py | 146 +-----------------------
 2 files changed, 266 insertions(+), 145 deletions(-)
 create mode 100644 upath/_flavour.py

diff --git a/upath/_flavour.py b/upath/_flavour.py
new file mode 100644
index 00000000..ed239e31
--- /dev/null
+++ b/upath/_flavour.py
@@ -0,0 +1,265 @@
+from __future__ import annotations
+
+import ntpath
+import os.path
+import posixpath
+import sys
+from functools import lru_cache
+from pathlib import PurePath
+from typing import Any
+from typing import Callable
+from typing import TypeAlias
+from urllib.parse import urlsplit
+
+from upath._protocol import get_upath_protocol
+from upath._protocol import strip_upath_protocol
+
+PathOrStr: TypeAlias = "str | PurePath | os.PathLike"
+
+
+class FSSpecFlavour:
+    """fsspec flavour for universal_pathlib
+
+    **INTERNAL AND VERY MUCH EXPERIMENTAL**
+
+    Implements the fsspec compatible low-level lexical operations on
+    PurePathBase-like objects.
+
+    Note:
+        In case you find yourself in need of subclassing FSSpecFlavour,
+        please open an issue in the universal_pathlib issue tracker:
+        https://github.com/fsspec/universal_pathlib/issues
+        Ideally we can find a way to make your use-case work by adding
+        more functionality to this class.
+
+    """
+
+    def __init__(
+        self,
+        *,
+        # URI behavior
+        join_prepends_protocol: bool = False,
+        join_like_urljoin: bool = False,
+        supports_empty_parts: bool = False,
+        supports_netloc: bool = False,
+        supports_query_parameters: bool = False,
+        supports_fragments: bool = False,
+        posixpath_only: bool = True,
+        # configurable separators
+        sep: str = "/",
+        altsep: str | None = None,
+    ):
+        self._owner = None
+        # separators
+        self.sep = sep
+        self.altsep = altsep
+        # configuration
+        self.join_prepends_protocol = join_prepends_protocol
+        self.join_like_urljoin = join_like_urljoin
+        self.supports_empty_parts = supports_empty_parts
+        self.supports_netloc = supports_netloc
+        self.supports_query_parameters = supports_query_parameters
+        self.supports_fragments = supports_fragments
+        self.posixpath_only = posixpath_only
+
+    def __set_name__(self, owner, name):
+        # helper to provide a more informative repr
+        self._owner = owner.__name__
+
+    def _asdict(self) -> dict[str, Any]:
+        """return a dict representation of the flavour's settings"""
+        dct = vars(self).copy()
+        dct.pop("_owner")
+        return dct
+
+    def __repr__(self):
+        return f"<{__name__}.{type(self).__name__} of {self._owner}>"
+
+    def join(self, __path: PathOrStr, *paths: PathOrStr) -> str:
+        """Join two or more path components, inserting '/' as needed."""
+        if isinstance(__path, (list, tuple)) and not paths:
+            # py38-py312 _flavour.join is Callable[[list[str]], str]
+            if not __path:
+                return ""
+            __path, *paths = __path
+
+        path = strip_upath_protocol(__path)
+        paths = map(strip_upath_protocol, paths)
+
+        if self.join_like_urljoin:
+            path = path.removesuffix("/")
+            sep = self.sep
+            for b in paths:
+                if b.startswith(sep):
+                    path = b
+                elif not path:
+                    path += b
+                else:
+                    path += sep + b
+            joined = path
+        elif self.posixpath_only:
+            joined = posixpath.join(path, *paths)
+        else:
+            joined = os.path.join(path, *paths)
+
+        if self.join_prepends_protocol and (protocol := get_upath_protocol(__path)):
+            joined = f"{protocol}://{joined}"
+
+        return joined
+
+    def splitroot(self, __path: PathOrStr) -> tuple[str, str, str]:
+        """Split a path in the drive, the root and the rest."""
+        if self.supports_fragments or self.supports_query_parameters:
+            url = urlsplit(__path)
+            drive = url._replace(path="", query="", fragment="").geturl()
+            path = url._replace(scheme="", netloc="").geturl()
+            # root = "/" if path.startswith("/") else ""
+            root = "/"  # emulate upath.core.UPath < 3.12 behaviour
+            return drive, root, path.removeprefix("/")
+
+        path = strip_upath_protocol(__path)
+        if self.supports_netloc:
+            protocol = get_upath_protocol(__path)
+            if protocol:
+                drive, root, tail = path.partition("/")
+                return drive, root or "/", tail
+            else:
+                return "", "", path
+        elif self.posixpath_only:
+            return _get_splitroot(posixpath)(path)
+        else:
+            drv, root, path = _get_splitroot(os.path)(path)
+            if os.name == "nt" and not drv:
+                drv = "C:"
+            return drv, root, path
+
+    def splitdrive(self, __path: PathOrStr) -> tuple[str, str]:
+        """Split a path into drive and path."""
+        if self.supports_fragments or self.supports_query_parameters:
+            path = strip_upath_protocol(__path)
+            url = urlsplit(path)
+            path = url._replace(scheme="", netloc="").geturl()
+            drive = url._replace(path="", query="", fragment="").geturl()
+            return drive, path
+
+        path = strip_upath_protocol(__path)
+        if self.supports_netloc:
+            protocol = get_upath_protocol(__path)
+            if protocol:
+                drive, root, tail = path.partition("/")
+                return drive, f"{root}{tail}"
+            else:
+                return "", path
+        elif self.posixpath_only:
+            return posixpath.splitdrive(path)
+        else:
+            drv, path = os.path.splitdrive(path)
+            if os.name == "nt" and not drv:
+                drv = "C:"
+            return drv, path
+
+    def normcase(self, __path: PathOrStr) -> str:
+        """Normalize case of pathname. Has no effect under Posix"""
+        if self.posixpath_only:
+            return posixpath.normcase(__path)
+        else:
+            return os.path.normcase(__path)
+
+    if sys.version_info < (3, 12):
+
+        def parse_parts(self, parts):
+            parsed = []
+            sep = self.sep
+            drv = root = ""
+            it = reversed(parts)
+            for part in it:
+                if part:
+                    drv, root, rel = self.splitroot(part)
+                    if not root or root and rel:
+                        for x in reversed(rel.split(sep)):
+                            parsed.append(sys.intern(x))
+
+            if drv or root:
+                parsed.append(drv + root)
+            parsed.reverse()
+            return drv, root, parsed
+
+        def join_parsed_parts(self, drv, root, parts, drv2, root2, parts2):
+            """
+            Join the two paths represented by the respective
+            (drive, root, parts) tuples.  Return a new (drive, root, parts) tuple.
+            """
+            if root2:
+                if not drv2 and drv:
+                    return drv, root2, [drv + root2] + parts2[1:]
+            elif drv2:
+                if drv2 == drv or self.casefold(drv2) == self.casefold(drv):
+                    # Same drive => second path is relative to the first
+                    return drv, root, parts + parts2[1:]
+            else:
+                # Second path is non-anchored (common case)
+                return drv, root, parts + parts2
+            return drv2, root2, parts2
+
+        def casefold(self, s: str) -> str:
+            """Casefold the string s."""
+            if self.posixpath_only or os.name != "nt":
+                return s
+            else:
+                return s.lower()
+
+
+@lru_cache
+def _get_splitroot(mod) -> Callable[[os.PathLike], tuple[str, str, str]]:
+    """return the splitroot function from the given module"""
+    if hasattr(mod, "splitroot"):
+        return mod.splitroot
+
+    elif mod is posixpath:
+
+        def splitroot(p):
+            p = os.fspath(p)
+            sep = "/"
+            empty = ""
+            if p[:1] != sep:
+                return empty, empty, p
+            elif p[1:2] != sep or p[2:3] == sep:
+                return empty, sep, p[1:]
+            else:
+                return empty, p[:2], p[2:]
+
+        return splitroot
+
+    elif mod is ntpath:
+
+        def splitroot(p):
+            p = os.fspath(p)
+            sep = "\\"
+            altsep = "/"
+            colon = ":"
+            unc_prefix = "\\\\?\\UNC\\"
+            empty = ""
+            normp = p.replace(altsep, sep)
+            if normp[:1] == sep:
+                if normp[1:2] == sep:
+                    start = 8 if normp[:8].upper() == unc_prefix else 2
+                    index = normp.find(sep, start)
+                    if index == -1:
+                        return p, empty, empty
+                    index2 = normp.find(sep, index + 1)
+                    if index2 == -1:
+                        return p, empty, empty
+                    return p[:index2], p[index2 : index2 + 1], p[index2 + 1 :]
+                else:
+                    return empty, p[:1], p[1:]
+            elif normp[1:2] == colon:
+                if normp[2:3] == sep:
+                    return p[:2], p[2:3], p[3:]
+                else:
+                    return p[:2], empty, p[2:]
+            else:
+                return empty, empty, p
+
+        return splitroot
+    else:
+        raise NotImplementedError(f"unsupported module: {mod!r}")
diff --git a/upath/core312plus.py b/upath/core312plus.py
index 216ab530..f6ad5492 100644
--- a/upath/core312plus.py
+++ b/upath/core312plus.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import os
-import posixpath
 import sys
 import warnings
 from copy import copy
@@ -24,8 +23,8 @@
 from fsspec import AbstractFileSystem
 from fsspec import get_filesystem_class
 
+from upath._flavour import FSSpecFlavour
 from upath._protocol import get_upath_protocol
-from upath._protocol import strip_upath_protocol
 from upath.registry import get_upath_class
 
 PathOrStr: TypeAlias = "str | PurePath | os.PathLike"
@@ -38,149 +37,6 @@ def __init__(self, parsed_url: SplitResult | None, **kwargs: Any) -> None:
         pass
 
 
-class FSSpecFlavour:
-    """fsspec flavour for universal_pathlib
-
-    **INTERNAL AND VERY MUCH EXPERIMENTAL**
-
-    Implements the fsspec compatible low-level lexical operations on
-    PurePathBase-like objects.
-
-    Note:
-        In case you find yourself in need of subclassing FSSpecFlavour,
-        please open an issue in the universal_pathlib issue tracker:
-        https://github.com/fsspec/universal_pathlib/issues
-        Ideally we can find a way to make your use-case work by adding
-        more functionality to this class.
-
-    """
-
-    def __init__(
-        self,
-        *,
-        # URI behavior
-        join_prepends_protocol: bool = False,
-        join_like_urljoin: bool = False,
-        supports_empty_parts: bool = False,
-        supports_netloc: bool = False,
-        supports_query_parameters: bool = False,
-        supports_fragments: bool = False,
-        posixpath_only: bool = True,
-        # configurable separators
-        sep: str = "/",
-        altsep: str | None = None,
-    ):
-        self._owner = None
-        # separators
-        self.sep = sep
-        self.altsep = altsep
-        # configuration
-        self.join_prepends_protocol = join_prepends_protocol
-        self.join_like_urljoin = join_like_urljoin
-        self.supports_empty_parts = supports_empty_parts
-        self.supports_netloc = supports_netloc
-        self.supports_query_parameters = supports_query_parameters
-        self.supports_fragments = supports_fragments
-        self.posixpath_only = posixpath_only
-
-    def __set_name__(self, owner, name):
-        # helper to provide a more informative repr
-        self._owner = owner.__name__
-
-    def _asdict(self) -> dict[str, Any]:
-        """return a dict representation of the flavour's settings"""
-        dct = vars(self).copy()
-        dct.pop("_owner")
-        return dct
-
-    def __repr__(self):
-        return f"<{__name__}.{type(self).__name__} of {self._owner}>"
-
-    def join(self, __path: PathOrStr, *paths: PathOrStr) -> str:
-        """Join two or more path components, inserting '/' as needed."""
-        path = strip_upath_protocol(__path)
-        paths = map(strip_upath_protocol, paths)
-
-        if self.join_like_urljoin:
-            path = path.removesuffix("/")
-            sep = self.sep
-            for b in paths:
-                if b.startswith(sep):
-                    path = b
-                elif not path:
-                    path += b
-                else:
-                    path += sep + b
-            joined = path
-        elif self.posixpath_only:
-            joined = posixpath.join(path, *paths)
-        else:
-            joined = os.path.join(path, *paths)
-
-        if self.join_prepends_protocol and (protocol := get_upath_protocol(__path)):
-            joined = f"{protocol}://{joined}"
-
-        return joined
-
-    def splitroot(self, __path: PathOrStr) -> tuple[str, str, str]:
-        """Split a path in the drive, the root and the rest."""
-        if self.supports_fragments or self.supports_query_parameters:
-            url = urlsplit(__path)
-            drive = url._replace(path="", query="", fragment="").geturl()
-            path = url._replace(scheme="", netloc="").geturl()
-            # root = "/" if path.startswith("/") else ""
-            root = "/"  # emulate upath.core.UPath < 3.12 behaviour
-            return drive, root, path.removeprefix("/")
-
-        path = strip_upath_protocol(__path)
-        if self.supports_netloc:
-            protocol = get_upath_protocol(__path)
-            if protocol:
-                drive, root, tail = path.partition("/")
-                return drive, root or "/", tail
-            else:
-                return "", "", path
-        elif self.posixpath_only:
-            return posixpath.splitroot(path)
-        else:
-            drv, root, path = os.path.splitroot(path)
-            if os.name == "nt" and not drv:
-                drv = "C:"
-            return drv, root, path
-
-    def splitdrive(self, __path: PathOrStr) -> tuple[str, str]:
-        """Split a path into drive and path."""
-        if self.supports_fragments or self.supports_query_parameters:
-            path = strip_upath_protocol(__path)
-            url = urlsplit(path)
-            path = url._replace(scheme="", netloc="").geturl()
-            drive = url._replace(path="", query="", fragment="").geturl()
-            return drive, path
-
-        path = strip_upath_protocol(__path)
-        if self.supports_netloc:
-            protocol = get_upath_protocol(__path)
-            if protocol:
-                drive, root, tail = path.partition("/")
-                return drive, f"{root}{tail}"
-            else:
-                return "", path
-        elif self.posixpath_only:
-            return posixpath.splitdrive(path)
-        else:
-            drv, path = os.path.splitdrive(path)
-            if os.name == "nt" and not drv:
-                drv = "C:"
-            return drv, path
-
-    def normcase(self, __path: PathOrStr) -> str:
-        """Normalize case of pathname. Has no effect under Posix"""
-        if self.posixpath_only:
-            return posixpath.normcase(__path)
-        else:
-            return os.path.normcase(__path)
-
-
 def _make_instance(cls, args, kwargs):
     """helper for pickling UPath instances"""
     return cls(*args, **kwargs)

From 87c7fbc84c0df7878d3d0f3314e2a9b7d43f92e2 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Fri, 2 Feb 2024 00:48:14 +0100
Subject: [PATCH 09/50] upath._protocol: add 'data' protocol matching

---
 upath/_protocol.py | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/upath/_protocol.py b/upath/_protocol.py
index e11407a9..9f5b0488 100644
--- a/upath/_protocol.py
+++ b/upath/_protocol.py
@@ -12,16 +12,21 @@
     "strip_upath_protocol",
 ]
 
-# Regular expression to match fsspec style protocols. Matches single
-# slash usage too.
+# Regular expression to match fsspec style protocols.
+# Matches single slash usage too for compatibility.
 _PROTOCOL_RE = re.compile(
     r"^(?P<protocol>[A-Za-z][A-Za-z0-9+]+):(?P<slashes>//?)(?P<path>.*)"
 )
 
+# Matches data URIs
+_DATA_URI_RE = re.compile(r"^data:[^,]*,")
+
 
 def _match_protocol(pth: str) -> str:
     if m := _PROTOCOL_RE.match(pth):
         return m.group("protocol")
+    elif _DATA_URI_RE.match(pth):
+        return "data"
     return ""
 
 
@@ -54,9 +59,9 @@ def strip_upath_protocol(pth: str | os.PathLike[str]) -> str:
     elif not isinstance(pth, str):
         pth = os.fspath(pth)
     if m := _PROTOCOL_RE.match(pth):
-        protocol = m.group("protocol")
-        path = m.group("path")
         if len(m.group("slashes")) == 1:
+            protocol = m.group("protocol")
+            path = m.group("path")
             pth = f"{protocol}:///{path}"
         return strip_fsspec_protocol(pth)
     else:

From d1a3af4c61e0ef2a7aa52b29346249f381a356d8 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Fri, 2 Feb 2024 11:55:32 +0100
Subject: [PATCH 10/50] upath: allow customization of init args for UPath 3.12+

---
 upath/core312plus.py | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/upath/core312plus.py b/upath/core312plus.py
index f6ad5492..b75c65f5 100644
--- a/upath/core312plus.py
+++ b/upath/core312plus.py
@@ -134,6 +134,14 @@ def __new__(
     def __init__(
         self, *args, protocol: str | None = None, **storage_options: Any
     ) -> None:
+        # allow subclasses to customize __init__ arg parsing
+        base_options = getattr(self, "_storage_options", {})
+        args, protocol, storage_options = type(self)._transform_init_args(
+            args, protocol, {**base_options, **storage_options}
+        )
+        if self._protocol != protocol and protocol:
+            self._protocol = protocol
+
         # retrieve storage_options
         if args:
             args0 = args[0]
@@ -171,6 +179,16 @@ def __init__(
 
     # === upath.UPath only ============================================
 
+    @classmethod
+    def _transform_init_args(
+        cls,
+        args: tuple[str | os.PathLike, ...],
+        protocol: str,
+        storage_options: dict[str, Any],
+    ) -> tuple[tuple[str | os.PathLike, ...], str, dict[str, Any]]:
+        """allow customization of init args in subclasses"""
+        return args, protocol, storage_options
+
     @property
     def protocol(self) -> str:
         return self._protocol

From 2a3d028091346665b741480016bcaf9f3dfe176a Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Fri, 2 Feb 2024 16:04:47 +0100
Subject: [PATCH 11/50] upath._protocol: add allow_unkown kwarg for
 strip_upath_protocol

---
 upath/_protocol.py | 15 +++++++++++++--
 1 file changed, 13 insertions(+), 2 deletions(-)

diff --git a/upath/_protocol.py b/upath/_protocol.py
index 9f5b0488..40dd621f 100644
--- a/upath/_protocol.py
+++ b/upath/_protocol.py
@@ -6,6 +6,7 @@
 from typing import Any
 
 from fsspec.core import strip_protocol as strip_fsspec_protocol
+from fsspec.spec import AbstractFileSystem
 
 __all__ = [
     "get_upath_protocol",
@@ -52,7 +53,11 @@ def get_upath_protocol(
     return protocol or pth_protocol or ""
 
 
-def strip_upath_protocol(pth: str | os.PathLike[str]) -> str:
+def strip_upath_protocol(
+    pth: str | os.PathLike[str],
+    *,
+    allow_unknown: bool = False,
+) -> str:
     """strip protocol from path"""
     if isinstance(pth, PurePath):
         pth = str(pth)
@@ -63,6 +68,12 @@ def strip_upath_protocol(pth: str | os.PathLike[str]) -> str:
             protocol = m.group("protocol")
             path = m.group("path")
             pth = f"{protocol}:///{path}"
-        return strip_fsspec_protocol(pth)
+        try:
+            return strip_fsspec_protocol(pth)
+        except ValueError as err:
+            if allow_unknown and str(err).endswith(m.group("protocol")):
+                # fsspec raised ValueError because the protocol is not registered
+                return AbstractFileSystem._strip_protocol(pth)
+            raise
     else:
         return pth

From 1eca195e86f7f23416e3f704e47244d7a560d15d Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Sun, 4 Feb 2024 18:25:11 +0100
Subject: [PATCH 12/50] upath.registry: correct class for fsspec file/local

---
 upath/registry.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/upath/registry.py b/upath/registry.py
index 1bf6f67f..032bc186 100644
--- a/upath/registry.py
+++ b/upath/registry.py
@@ -63,8 +63,8 @@ class _Registry(MutableMapping[str, "type[upath.UPath]"]):
         "abfss": "upath.implementations.cloud.AzurePath",
         "adl": "upath.implementations.cloud.AzurePath",
         "az": "upath.implementations.cloud.AzurePath",
-        "file": "upath.implementations.local.LocalPath",
-        "local": "upath.implementations.local.LocalPath",
+        "file": "upath.implementations.local.FilePath",
+        "local": "upath.implementations.local.FilePath",
         "gcs": "upath.implementations.cloud.GCSPath",
         "gs": "upath.implementations.cloud.GCSPath",
         "hdfs": "upath.implementations.hdfs.HDFSPath",

From 6ccddce77bf959fc9369b2e8e5741774cda5e6d9 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Sun, 4 Feb 2024 18:26:30 +0100
Subject: [PATCH 13/50] upath._flavour: allow unknown protocol

---
 upath/_flavour.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/upath/_flavour.py b/upath/_flavour.py
index ed239e31..830db2a1 100644
--- a/upath/_flavour.py
+++ b/upath/_flavour.py
@@ -117,7 +117,7 @@ def splitroot(self, __path: PathOrStr) -> tuple[str, str, str]:
             root = "/"  # emulate upath.core.UPath < 3.12 behaviour
             return drive, root, path.removeprefix("/")
 
-        path = strip_upath_protocol(__path)
+        path = strip_upath_protocol(__path, allow_unknown=True)
         if self.supports_netloc:
             protocol = get_upath_protocol(__path)
             if protocol:

From 9f8598bf20ca45edfc3c089f522b73b416b6fd9a Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 01:20:05 +0100
Subject: [PATCH 14/50] upath: use single implementation

---
 upath/_compat.py     | 383 +++++++++++++++++++++++++++++++++++++++++++
 upath/core312plus.py |  31 +++-
 2 files changed, 406 insertions(+), 8 deletions(-)
 create mode 100644 upath/_compat.py

diff --git a/upath/_compat.py b/upath/_compat.py
new file mode 100644
index 00000000..f26b4d78
--- /dev/null
+++ b/upath/_compat.py
@@ -0,0 +1,383 @@
+import ntpath
+import os
+import posixpath
+import sys
+import warnings
+from collections.abc import Sequence
+from pathlib import Path
+from pathlib import PurePath
+
+__all__ = [
+    "PathlibPathShim",
+    "str_remove_prefix",
+    "str_remove_suffix",
+]
+
+
+if sys.version_info >= (3, 12):  # noqa: C901
+
+    class PathlibPathShim:
+        """no need to shim pathlib.Path in Python 3.12+"""
+
+        __slots__ = ()
+        __missing_py312_slots__ = ()
+
+        def __init__(self, *args):
+            super().__init__(*args)
+
+else:
+
+    def _get_missing_py312_pathlib_slots():
+        """Return a tuple of slots that are present in Python 3.12's
+        pathlib.Path but not in the current version of pathlib.Path
+        """
+        py312_slots = (
+            "_raw_paths",
+            "_drv",
+            "_root",
+            "_tail_cached",
+            "_str",
+            "_str_normcase_cached",
+            "_parts_normcase_cached",
+            "_lines_cached",
+            "_hash",
+        )
+        current_slots = [
+            slot for cls in Path.__mro__ for slot in getattr(cls, "__slots__", [])
+        ]
+        return tuple([slot for slot in py312_slots if slot not in current_slots])
+
+    class PathlibPathShim:
+        """A compatibility shim for python < 3.12
+
+        Basically vendoring the functionality of pathlib.Path from Python 3.12
+        that's not overwritten in upath.core.UPath
+
+        """
+
+        __slots__ = ()
+        __missing_py312_slots__ = _get_missing_py312_pathlib_slots()
+
+        def __init__(self, *args):
+            paths = []
+            for arg in args:
+                if isinstance(arg, PurePath) and hasattr(arg, "_raw_paths"):
+                    if arg._flavour is ntpath and self._flavour is posixpath:
+                        # GH-103631: Convert separators for backwards compatibility.
+                        paths.extend(path.replace("\\", "/") for path in arg._raw_paths)
+                    else:
+                        paths.extend(arg._raw_paths)
+                else:
+                    try:
+                        path = os.fspath(arg)
+                    except TypeError:
+                        path = arg
+                    if not isinstance(path, str):
+                        raise TypeError(
+                            "argument should be a str or an os.PathLike "
+                            "object where __fspath__ returns a str, "
+                            f"not {type(path).__name__!r}"
+                        )
+                    paths.append(path)
+            self._raw_paths = paths
+
+        @classmethod
+        def _parse_path(cls, path):
+            if not path:
+                return "", "", []
+            sep = cls._flavour.sep
+            altsep = cls._flavour.altsep
+            if altsep:
+                path = path.replace(altsep, sep)
+            drv, root, rel = cls._flavour.splitroot(path)
+            if not root and drv.startswith(sep) and not drv.endswith(sep):
+                drv_parts = drv.split(sep)
+                if len(drv_parts) == 4 and drv_parts[2] not in "?.":
+                    # e.g. //server/share
+                    root = sep
+                elif len(drv_parts) == 6:
+                    # e.g. //?/unc/server/share
+                    root = sep
+            parsed = [sys.intern(str(x)) for x in rel.split(sep) if x and x != "."]
+            return drv, root, parsed
+
+        def _load_parts(self):
+            paths = self._raw_paths
+            if len(paths) == 0:
+                path = ""
+            elif len(paths) == 1:
+                path = paths[0]
+            else:
+                path = self._flavour.join(*paths)
+            drv, root, tail = self._parse_path(path)
+            self._drv = drv
+            self._root = root
+            self._tail_cached = tail
+
+        def _from_parsed_parts(self, drv, root, tail):
+            path_str = self._format_parsed_parts(drv, root, tail)
+            path = self.with_segments(path_str)
+            path._str = path_str or "."
+            path._drv = drv
+            path._root = root
+            path._tail_cached = tail
+            return path
+
+        @classmethod
+        def _format_parsed_parts(cls, drv, root, tail):
+            if drv or root:
+                return drv + root + cls._flavour.sep.join(tail)
+            elif tail and cls._flavour.splitdrive(tail[0])[0]:
+                tail = ["."] + tail
+            return cls._flavour.sep.join(tail)
+
+        def __str__(self):
+            try:
+                return self._str
+            except AttributeError:
+                self._str = (
+                    self._format_parsed_parts(self.drive, self.root, self._tail) or "."
+                )
+                return self._str
+
+        @property
+        def drive(self):
+            try:
+                return self._drv
+            except AttributeError:
+                self._load_parts()
+                return self._drv
+
+        @property
+        def root(self):
+            try:
+                return self._root
+            except AttributeError:
+                self._load_parts()
+                return self._root
+
+        @property
+        def _tail(self):
+            try:
+                return self._tail_cached
+            except AttributeError:
+                self._load_parts()
+                return self._tail_cached
+
+        @property
+        def anchor(self):
+            anchor = self.drive + self.root
+            return anchor
+
+        @property
+        def name(self):
+            tail = self._tail
+            if not tail:
+                return ""
+            return tail[-1]
+
+        @property
+        def suffix(self):
+            name = self.name
+            i = name.rfind(".")
+            if 0 < i < len(name) - 1:
+                return name[i:]
+            else:
+                return ""
+
+        @property
+        def suffixes(self):
+            name = self.name
+            if name.endswith("."):
+                return []
+            name = name.lstrip(".")
+            return ["." + suffix for suffix in name.split(".")[1:]]
+
+        @property
+        def stem(self):
+            name = self.name
+            i = name.rfind(".")
+            if 0 < i < len(name) - 1:
+                return name[:i]
+            else:
+                return name
+
+        def with_name(self, name):
+            if not self.name:
+                raise ValueError(f"{self!r} has an empty name")
+            f = self._flavour
+            if (
+                not name
+                or f.sep in name
+                or (f.altsep and f.altsep in name)
+                or name == "."
+            ):
+                raise ValueError("Invalid name %r" % (name))
+            return self._from_parsed_parts(
+                self.drive, self.root, self._tail[:-1] + [name]
+            )
+
+        def with_stem(self, stem):
+            return self.with_name(stem + self.suffix)
+
+        def with_suffix(self, suffix):
+            f = self._flavour
+            if f.sep in suffix or f.altsep and f.altsep in suffix:
+                raise ValueError(f"Invalid suffix {suffix!r}")
+            if suffix and not suffix.startswith(".") or suffix == ".":
+                raise ValueError("Invalid suffix %r" % (suffix))
+            name = self.name
+            if not name:
+                raise ValueError(f"{self!r} has an empty name")
+            old_suffix = self.suffix
+            if not old_suffix:
+                name = name + suffix
+            else:
+                name = name[: -len(old_suffix)] + suffix
+            return self._from_parsed_parts(
+                self.drive, self.root, self._tail[:-1] + [name]
+            )
+
+        def relative_to(self, other, /, *_deprecated, walk_up=False):
+            if _deprecated:
+                msg = (
+                    "support for supplying more than one positional argument "
+                    "to pathlib.PurePath.relative_to() is deprecated and "
+                    "scheduled for removal in Python {remove}"
+                )
+                warnings._deprecated(
+                    "pathlib.PurePath.relative_to(*args)", msg, remove=(3, 14)
+                )
+            other = self.with_segments(other, *_deprecated)
+            for step, path in enumerate([other] + list(other.parents)):  # noqa: B007
+                if self.is_relative_to(path):
+                    break
+                elif not walk_up:
+                    raise ValueError(
+                        f"{str(self)!r} is not in the subpath of {str(other)!r}"
+                    )
+                elif path.name == "..":
+                    raise ValueError(f"'..' segment in {str(other)!r} cannot be walked")
+            else:
+                raise ValueError(
+                    f"{str(self)!r} and {str(other)!r} have different anchors"
+                )
+            parts = [".."] * step + self._tail[len(path._tail) :]
+            return self.with_segments(*parts)
+
+        def is_relative_to(self, other, /, *_deprecated):
+            if _deprecated:
+                msg = (
+                    "support for supplying more than one argument to "
+                    "pathlib.PurePath.is_relative_to() is deprecated and "
+                    "scheduled for removal in Python {remove}"
+                )
+                warnings._deprecated(
+                    "pathlib.PurePath.is_relative_to(*args)", msg, remove=(3, 14)
+                )
+            other = self.with_segments(other, *_deprecated)
+            return other == self or other in self.parents
+
+        @property
+        def parts(self):
+            if self.drive or self.root:
+                return (self.drive + self.root,) + tuple(self._tail)
+            else:
+                return tuple(self._tail)
+
+        def joinpath(self, *pathsegments):
+            return self.with_segments(self, *pathsegments)
+
+        def __truediv__(self, key):
+            try:
+                return self.joinpath(key)
+            except TypeError:
+                return NotImplemented
+
+        def __rtruediv__(self, key):
+            try:
+                return self.with_segments(key, self)
+            except TypeError:
+                return NotImplemented
+
+        @property
+        def parent(self):
+            drv = self.drive
+            root = self.root
+            tail = self._tail
+            if not tail:
+                return self
+            return self._from_parsed_parts(drv, root, tail[:-1])
+
+        @property
+        def parents(self):
+            return _PathParents(self)
+
+        def _make_child_relpath(self, name):
+            path_str = str(self)
+            tail = self._tail
+            if tail:
+                path_str = f"{path_str}{self._flavour.sep}{name}"
+            elif path_str != ".":
+                path_str = f"{path_str}{name}"
+            else:
+                path_str = name
+            path = self.with_segments(path_str)
+            path._str = path_str
+            path._drv = self.drive
+            path._root = self.root
+            path._tail_cached = tail + [name]
+            return path
+
+        def lchmod(self, mode):
+            """
+            Like chmod(), except if the path points to a symlink, the symlink's
+            permissions are changed, rather than its target's.
+            """
+            self.chmod(mode, follow_symlinks=False)
+
+    class _PathParents(Sequence):
+        __slots__ = ("_path", "_drv", "_root", "_tail")
+
+        def __init__(self, path):
+            self._path = path
+            self._drv = path.drive
+            self._root = path.root
+            self._tail = path._tail
+
+        def __len__(self):
+            return len(self._tail)
+
+        def __getitem__(self, idx):
+            if isinstance(idx, slice):
+                return tuple(self[i] for i in range(*idx.indices(len(self))))
+
+            if idx >= len(self) or idx < -len(self):
+                raise IndexError(idx)
+            if idx < 0:
+                idx += len(self)
+            return self._path._from_parsed_parts(
+                self._drv, self._root, self._tail[: -idx - 1]
+            )
+
+        def __repr__(self):
+            return f"<{type(self._path).__name__}.parents>"
+
+
+if sys.version_info >= (3, 9):
+    str_remove_suffix = str.removesuffix
+    str_remove_prefix = str.removeprefix
+
+else:
+
+    def str_remove_suffix(s: str, suffix: str) -> str:
+        if s.endswith(suffix):
+            return s[: -len(suffix)]
+        else:
+            return s
+
+    def str_remove_prefix(s: str, prefix: str) -> str:
+        if s.startswith(prefix):
+            return s[len(prefix) :]
+        else:
+            return s
diff --git a/upath/core312plus.py b/upath/core312plus.py
index b75c65f5..47f469d5 100644
--- a/upath/core312plus.py
+++ b/upath/core312plus.py
@@ -5,12 +5,10 @@
 import warnings
 from copy import copy
 from pathlib import Path
-from pathlib import PurePath
 from types import MappingProxyType
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Mapping
-from typing import TypeAlias
 from typing import cast
 from urllib.parse import SplitResult
 from urllib.parse import urlsplit
@@ -23,12 +21,13 @@
 from fsspec import AbstractFileSystem
 from fsspec import get_filesystem_class
 
+from upath._compat import PathlibPathShim
+from upath._compat import str_remove_prefix
+from upath._compat import str_remove_suffix
 from upath._flavour import FSSpecFlavour
 from upath._protocol import get_upath_protocol
 from upath.registry import get_upath_class
 
-PathOrStr: TypeAlias = "str | PurePath | os.PathLike"
-
 
 class _FSSpecAccessor:
     """this is a compatibility shim and will be removed"""
@@ -42,11 +41,12 @@ def _make_instance(cls, args, kwargs):
     return cls(*args, **kwargs)
 
 
-class UPath(Path):
+class UPath(PathlibPathShim, Path):
     __slots__ = (
         "_protocol",
         "_storage_options",
         "_fs_cached",
+        *PathlibPathShim.__missing_py312_slots__,
     )
     if TYPE_CHECKING:
         _protocol: str
@@ -330,6 +330,21 @@ def as_uri(self):
     def is_reserved(self):
         return False
 
+    def __eq__(self, other):
+        if not isinstance(other, UPath):
+            return NotImplemented
+        return (
+            self.path == other.path
+            and self.storage_options == other.storage_options
+            and (
+                get_filesystem_class(self.protocol)
+                == get_filesystem_class(other.protocol)
+            )
+        )
+
+    def __hash__(self):
+        return hash((self.path, self.storage_options, self.protocol))
+
     def relative_to(self, other, /, *_deprecated, walk_up=False):
         if isinstance(other, UPath) and self.storage_options != other.storage_options:
             raise ValueError(
@@ -407,7 +422,7 @@ def iterdir(self):
                 # Yielding a path object for these makes little sense
                 continue
             # only want the path name with iterdir
-            _, _, name = name.removesuffix("/").rpartition(self._flavour.sep)
+            _, _, name = str_remove_suffix(name, "/").rpartition(self._flavour.sep)
             yield base._make_child_relpath(name)
 
     def _scandir(self):
@@ -422,14 +437,14 @@ def glob(self, pattern: str, *, case_sensitive=None):
         path_pattern = self.joinpath(pattern).path
         sep = self._flavour.sep
         for name in self.fs.glob(path_pattern):
-            name = name.removeprefix(self.path).removeprefix(sep)
+            name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
             yield self.joinpath(name)
 
     def rglob(self, pattern: str, *, case_sensitive=None):
         r_path_pattern = self.joinpath("**", pattern).path
         sep = self._flavour.sep
         for name in self.fs.glob(r_path_pattern):
-            name = name.removeprefix(self.path).removeprefix(sep)
+            name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
             yield self.joinpath(name)
 
     @classmethod

From e9b78d5d49b02159be3dd679113140883f0da392 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 02:54:55 +0100
Subject: [PATCH 15/50] upath: make flavour compatible with py3.8+

---
 upath/_flavour.py    | 19 +++++++++++++++----
 upath/core312plus.py | 11 ++++++++---
 2 files changed, 23 insertions(+), 7 deletions(-)

diff --git a/upath/_flavour.py b/upath/_flavour.py
index 830db2a1..04216965 100644
--- a/upath/_flavour.py
+++ b/upath/_flavour.py
@@ -8,13 +8,24 @@
 from pathlib import PurePath
 from typing import Any
 from typing import Callable
-from typing import TypeAlias
+from typing import Union
 from urllib.parse import urlsplit
 
+if sys.version_info >= (3, 12):
+    from typing import TypeAlias
+else:
+    TypeAlias = Any
+
+from upath._compat import str_remove_prefix
+from upath._compat import str_remove_suffix
 from upath._protocol import get_upath_protocol
 from upath._protocol import strip_upath_protocol
 
-PathOrStr: TypeAlias = "str | PurePath | os.PathLike"
+PathOrStr: TypeAlias = Union[str, PurePath, os.PathLike]
+
+__all__ = [
+    "FSSpecFlavour",
+]
 
 
 class FSSpecFlavour:
@@ -87,7 +98,7 @@ def join(self, __path: PathOrStr, *paths: PathOrStr) -> str:
         paths = map(strip_upath_protocol, paths)
 
         if self.join_like_urljoin:
-            path = path.removesuffix("/")
+            path = str_remove_suffix(path, "/")
             sep = self.sep
             for b in paths:
                 if b.startswith(sep):
@@ -115,7 +126,7 @@ def splitroot(self, __path: PathOrStr) -> tuple[str, str, str]:
             path = url._replace(scheme="", netloc="").geturl()
             # root = "/" if path.startswith("/") else ""
             root = "/"  # emulate upath.core.UPath < 3.12 behaviour
-            return drive, root, path.removeprefix("/")
+            return drive, root, str_remove_prefix(path, "/")
 
         path = strip_upath_protocol(__path, allow_unknown=True)
         if self.supports_netloc:
diff --git a/upath/core312plus.py b/upath/core312plus.py
index 47f469d5..e3be4ad1 100644
--- a/upath/core312plus.py
+++ b/upath/core312plus.py
@@ -290,7 +290,7 @@ def with_segments(self, *pathsegments):
 
     @classmethod
     def _parse_path(cls, path):
-        if cls._flavour.supports_empty_parts:
+        if getattr(cls._flavour, "supports_empty_parts", False):
             drv, root, rel = cls._flavour.splitroot(path)
             if not root:
                 parsed = []
@@ -410,7 +410,9 @@ def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None)
         return self.fs.open(self.path, mode)  # fixme
 
     def iterdir(self):
-        if self._flavour.supports_empty_parts and self.parts[-1:] == ("",):
+        if getattr(self._flavour, "supports_empty_parts", False) and self.parts[
+            -1:
+        ] == ("",):
             base = self.with_segments(self.anchor, *self._tail[:-1])
         else:
             base = self
@@ -478,7 +480,10 @@ def resolve(self, strict: bool = False) -> Self:
             if part == "..":
                 if resolved:
                     resolved.pop()
-                if self._flavour.supports_empty_parts and idx == last_idx:
+                if (
+                    getattr(self._flavour, "supports_empty_parts", False)
+                    and idx == last_idx
+                ):
                     resolved.append("")
             elif part != ".":
                 resolved.append(part)

From dbb7095654ba716f92f91e71682e545be1181225 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 02:58:43 +0100
Subject: [PATCH 16/50] upath.implementations: simplify UPath subclasses

---
 upath/implementations/cloud.py  | 153 +++++---------------
 upath/implementations/hdfs.py   |  57 ++------
 upath/implementations/http.py   | 187 +++++-------------------
 upath/implementations/local.py  | 247 ++++++++++++++------------------
 upath/implementations/memory.py |  78 ++--------
 upath/implementations/webdav.py | 137 +++++-------------
 6 files changed, 233 insertions(+), 626 deletions(-)

diff --git a/upath/implementations/cloud.py b/upath/implementations/cloud.py
index c9ad05e7..2202a280 100644
--- a/upath/implementations/cloud.py
+++ b/upath/implementations/cloud.py
@@ -1,131 +1,42 @@
 from __future__ import annotations
 
-import re
-import sys
-import warnings
 from typing import Any
 
 import upath.core
+from upath._flavour import FSSpecFlavour
 
 
-class _CloudAccessor(upath.core._FSSpecAccessor):
-    def _format_path(self, path):
-        """
-        netloc has already been set to project via `CloudPath._from_parts`
-        """
-        return f"{path._url.netloc}/{path._path.lstrip('/')}"
-
-    def mkdir(self, path, create_parents=True, **kwargs):
-        _path = self._format_path(path)
-        if (
-            not create_parents
-            and not kwargs.get("exist_ok", False)
-            and self._fs.exists(_path)
-        ):
-            raise FileExistsError(_path)
-        return super().mkdir(path, create_parents=create_parents, **kwargs)
-
-
-class CloudPath(upath.core.UPath):
-    _default_accessor = _CloudAccessor
-
-    @classmethod
-    def _from_parts(cls, args, url=None, **kwargs):
-        if kwargs.get("bucket") and url is not None:
-            bucket = kwargs.pop("bucket")
-            url = url._replace(netloc=bucket)
-        obj = super()._from_parts(args, url, **kwargs)
-        return obj
-
-    @classmethod
-    def _from_parsed_parts(cls, drv, root, parts, url=None, **kwargs):
-        if kwargs.get("bucket") and url is not None:
-            bucket = kwargs.pop("bucket")
-            url = url._replace(netloc=bucket)
-        obj = super()._from_parsed_parts(drv, root, parts, url=url, **kwargs)
-        return obj
-
-    def _sub_path(self, name):
-        """
-        `gcsfs` and `s3fs` return the full path as `<bucket>/<path>` with
-        `listdir` and `glob`. However, in `iterdir` and `glob` we only want the
-        relative path to `self`.
-        """
-        sp = re.escape(self._path)
-        netloc = self._url.netloc
-        return re.sub(
-            f"^({netloc})?/?({sp}|{sp[1:]})/?",
-            "",
-            name,
-        )
-
-    def joinpath(self, *args):
-        if self._url.netloc:
-            return super().joinpath(*args)
-
-        # if no bucket is defined for self
-        sep = self._flavour.sep
-        args_list = []
-        for arg in args:
-            if isinstance(arg, list):
-                warnings.warn(
-                    "lists as arguments to joinpath are deprecated",
-                    DeprecationWarning,
-                    stacklevel=2,
-                )
-                args_list.extend(arg)
-            else:
-                args_list.extend(arg.split(sep))
-        bucket = args_list.pop(0)
-        return type(self)(
-            "/",
-            *args_list,
-            **self.storage_options,
-            bucket=bucket,
-            scheme=self.protocol,
-        )
-
-    @property
-    def path(self) -> str:
-        if self._url is None:
-            raise RuntimeError(str(self))
-        return f"{self._url.netloc}{super()._path}"
-
-
-if sys.version_info >= (3, 12):
-    from upath.core312plus import FSSpecFlavour
-
-    class CloudPath(upath.core312plus.UPath):  # noqa
-        __slots__ = ()
-        _flavour = FSSpecFlavour(
-            join_prepends_protocol=True,
-            supports_netloc=True,
-        )
-
-        def __init__(
-            self, *args, protocol: str | None = None, **storage_options: Any
-        ) -> None:
-            if "bucket" in storage_options:
-                bucket = storage_options.pop("bucket")
-                args = [f"{self._protocol}://{bucket}/", *args]
-            super().__init__(*args, protocol=protocol, **storage_options)
-
-        def mkdir(
-            self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False
-        ) -> None:
-            if not parents and not exist_ok and self.exists():
-                raise FileExistsError(self.path)
-            super().mkdir(mode=mode, parents=parents, exist_ok=exist_ok)
-
-        def iterdir(self):
-            if self.is_file():
-                raise NotADirectoryError(str(self))
-            yield from super().iterdir()
-
-        def relative_to(self, other, /, *_deprecated, walk_up=False):
-            # use the parent implementation for the ValueError logic
-            super().relative_to(other, *_deprecated, walk_up=False)
-            return self
+class CloudPath(upath.core.UPath):  # noqa
+    __slots__ = ()
+    _flavour = FSSpecFlavour(
+        join_prepends_protocol=True,
+        supports_netloc=True,
+    )
+
+    def __init__(
+        self, *args, protocol: str | None = None, **storage_options: Any
+    ) -> None:
+        if "bucket" in storage_options:
+            bucket = storage_options.pop("bucket")
+            args = [f"{self._protocol}://{bucket}/", *args]
+        super().__init__(*args, protocol=protocol, **storage_options)
+
+    def mkdir(
+        self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False
+    ) -> None:
+        if not parents and not exist_ok and self.exists():
+            raise FileExistsError(self.path)
+        super().mkdir(mode=mode, parents=parents, exist_ok=exist_ok)
+
+    def iterdir(self):
+        if self.is_file():
+            raise NotADirectoryError(str(self))
+        yield from super().iterdir()
+
+    def relative_to(self, other, /, *_deprecated, walk_up=False):
+        # use the parent implementation for the ValueError logic
+        super().relative_to(other, *_deprecated, walk_up=False)
+        return self
 
 
 class GCSPath(CloudPath):
diff --git a/upath/implementations/hdfs.py b/upath/implementations/hdfs.py
index 50b1c75c..34d12723 100644
--- a/upath/implementations/hdfs.py
+++ b/upath/implementations/hdfs.py
@@ -1,56 +1,17 @@
 from __future__ import annotations
 
-import sys
-
 import upath.core
 
 
-class _HDFSAccessor(upath.core._FSSpecAccessor):
-    def __init__(self, parsed_url, *args, **kwargs):
-        super().__init__(parsed_url, *args, **kwargs)
-        self._fs.root_marker = "/"
-
-    def touch(self, path, **kwargs):
-        kwargs.pop("truncate", None)
-        super().touch(path, **kwargs)
-
-    def mkdir(self, path, create_parents=True, **kwargs):
-        pth = self._format_path(path)
-        if create_parents:
-            return self._fs.makedirs(pth, **kwargs)
-        else:
-            if not kwargs.get("exist_ok", False) and self._fs.exists(pth):
-                raise FileExistsError(pth)
-            print(kwargs, self._fs.exists(pth), pth)
-            return self._fs.mkdir(pth, create_parents=create_parents, **kwargs)
-
-    def listdir(self, path, **kwargs):
-        try:
-            yield from super().listdir(path, **kwargs)
-        except OSError as err:
-            if err.args and err.args[0].startswith(
-                "GetFileInfo expects base_dir of selector to be a directory"
-            ):
-                raise NotADirectoryError(path)
-            raise
-
-
 class HDFSPath(upath.core.UPath):
-    _default_accessor = _HDFSAccessor
-
-
-if sys.version_info >= (3, 12):
-    import upath.core312plus
-
-    class HDFSPath(upath.core312plus.UPath):  # noqa
-        __slots__ = ()
+    __slots__ = ()
 
-        def mkdir(self, mode=0o777, parents=False, exist_ok=False):
-            if not exist_ok and self.exists():
-                raise FileExistsError(str(self))
-            super().mkdir(mode=mode, parents=parents, exist_ok=exist_ok)
+    def mkdir(self, mode=0o777, parents=False, exist_ok=False):
+        if not exist_ok and self.exists():
+            raise FileExistsError(str(self))
+        super().mkdir(mode=mode, parents=parents, exist_ok=exist_ok)
 
-        def iterdir(self):
-            if self.is_file():
-                raise NotADirectoryError(str(self))
-            yield from super().iterdir()
+    def iterdir(self):
+        if self.is_file():
+            raise NotADirectoryError(str(self))
+        yield from super().iterdir()
diff --git a/upath/implementations/http.py b/upath/implementations/http.py
index 9f647316..46d36024 100644
--- a/upath/implementations/http.py
+++ b/upath/implementations/http.py
@@ -1,77 +1,73 @@
 from __future__ import annotations
 
-import sys
 from itertools import chain
-from urllib.parse import urlunsplit
 
 from fsspec.asyn import sync
 
 import upath.core
+from upath._flavour import FSSpecFlavour
 
 
-class _HTTPAccessor(upath.core._FSSpecAccessor):
-    def __init__(self, parsed_url, *args, **kwargs):
-        super().__init__(parsed_url, *args, **kwargs)
-
-    def _format_path(self, path):
-        return str(path)
+class HTTPPath(upath.core.UPath):  # noqa
+    _flavour = FSSpecFlavour(
+        join_like_urljoin=True,
+        supports_empty_parts=True,
+        supports_netloc=True,
+        supports_query_parameters=True,
+        supports_fragments=True,
+    )
 
+    @property
+    def root(self) -> str:
+        return super().root or "/"
 
-class HTTPPath(upath.core.UPath):
-    _default_accessor = _HTTPAccessor
+    def __str__(self):
+        return super(upath.core.UPath, self).__str__()
 
-    def is_dir(self):
+    def is_file(self):
         try:
-            return self._path_type() == "directory"
+            next(super().iterdir())
+        except (StopIteration, NotADirectoryError):
+            return True
         except FileNotFoundError:
             return False
+        else:
+            return False
 
-    def is_file(self):
+    def is_dir(self):
         try:
-            return self._path_type() == "file"
+            next(super().iterdir())
+        except (StopIteration, NotADirectoryError):
+            return False
         except FileNotFoundError:
             return False
+        else:
+            return True
 
-    def _path_type(self):
+    def iterdir(self):
+        it = iter(super().iterdir())
         try:
-            next(self.iterdir())
+            item0 = next(it)
         except (StopIteration, NotADirectoryError):
-            return "file"
+            raise NotADirectoryError(str(self))
+        except FileNotFoundError:
+            raise FileNotFoundError(str(self))
         else:
-            return "directory"
-
-    def _sub_path(self, name):
-        """
-        `fsspec` returns the full path as `scheme://netloc/<path>` with
-        `listdir` and `glob`. However, in `iterdir` and `glob` we only want the
-        relative path to `self`.
-        """
-        complete_address = self._format_parsed_parts(
-            None, None, [self._path], url=self._url, **self._kwargs
-        )
-
-        if name.startswith(complete_address):
-            name = name[len(complete_address) :]  # noqa: E203
-        name = name.strip("/")
-
-        return name
+            yield from chain([item0], it)
 
     def resolve(
-        self: HTTPPath, strict: bool = False, follow_redirects: bool = True
+        self: HTTPPath,
+        strict: bool = False,
+        follow_redirects: bool = True,
     ) -> HTTPPath:
         """Normalize the path and resolve redirects."""
         # Normalise the path
         resolved_path = super().resolve(strict=strict)
 
         if follow_redirects:
-            # Ensure we have a url
-            parsed_url = resolved_path._url
-            if parsed_url is None:
-                return resolved_path
-            else:
-                url = parsed_url.geturl()
             # Get the fsspec fs
-            fs = resolved_path._accessor._fs
+            fs = self.fs
+            url = str(self)
             # Ensure we have a session
             session = sync(fs.loop, fs.set_session)
             # Use HEAD requests if the server allows it, falling back to GETs
@@ -87,110 +83,3 @@ def resolve(
                     break
 
         return resolved_path
-
-    @property
-    def drive(self):
-        return f"{self._url.scheme}://{self._url.netloc}"
-
-    @property
-    def anchor(self) -> str:
-        return self.drive + self.root
-
-    @property
-    def parts(self) -> tuple[str, ...]:
-        parts = super().parts
-        if not parts:
-            return ()
-        p0, *partsN = parts
-        if p0 == "/":
-            p0 = self.anchor
-        if not partsN and self._url and self._url.path == "/":
-            partsN = [""]
-        return (p0, *partsN)
-
-    @property
-    def path(self) -> str:
-        # http filesystems use the full url as path
-        if self._url is None:
-            raise RuntimeError(str(self))
-        return urlunsplit(self._url)
-
-
-if sys.version_info >= (3, 12):  # noqa
-    from upath.core312plus import FSSpecFlavour
-
-    class HTTPPath(upath.core312plus.UPath):  # noqa
-        _flavour = FSSpecFlavour(
-            join_like_urljoin=True,
-            supports_empty_parts=True,
-            supports_netloc=True,
-            supports_query_parameters=True,
-            supports_fragments=True,
-        )
-
-        @property
-        def root(self) -> str:
-            return super().root or "/"
-
-        def __str__(self):
-            return super(upath.core312plus.UPath, self).__str__()
-
-        def is_file(self):
-            try:
-                next(super().iterdir())
-            except (StopIteration, NotADirectoryError):
-                return True
-            except FileNotFoundError:
-                return False
-            else:
-                return False
-
-        def is_dir(self):
-            try:
-                next(super().iterdir())
-            except (StopIteration, NotADirectoryError):
-                return False
-            except FileNotFoundError:
-                return False
-            else:
-                return True
-
-        def iterdir(self):
-            it = iter(super().iterdir())
-            try:
-                item0 = next(it)
-            except (StopIteration, NotADirectoryError):
-                raise NotADirectoryError(str(self))
-            except FileNotFoundError:
-                raise FileNotFoundError(str(self))
-            else:
-                yield from chain([item0], it)
-
-        def resolve(
-            self: HTTPPath,
-            strict: bool = False,
-            follow_redirects: bool = True,
-        ) -> HTTPPath:
-            """Normalize the path and resolve redirects."""
-            # Normalise the path
-            resolved_path = super().resolve(strict=strict)
-
-            if follow_redirects:
-                # Get the fsspec fs
-                fs = self.fs
-                url = str(self)
-                # Ensure we have a session
-                session = sync(fs.loop, fs.set_session)
-                # Use HEAD requests if the server allows it, falling back to GETs
-                for method in (session.head, session.get):
-                    r = sync(fs.loop, method, url, allow_redirects=True)
-                    try:
-                        r.raise_for_status()
-                    except Exception as exc:
-                        if method == session.get:
-                            raise FileNotFoundError(self) from exc
-                    else:
-                        resolved_path = HTTPPath(str(r.url))
-                        break
-
-            return resolved_path
diff --git a/upath/implementations/local.py b/upath/implementations/local.py
index e51d3871..406ee172 100644
--- a/upath/implementations/local.py
+++ b/upath/implementations/local.py
@@ -1,17 +1,16 @@
 from __future__ import annotations
 
-import os
+import pathlib
 import sys
 from inspect import ismemberdescriptor
 from pathlib import Path
 from pathlib import PosixPath
 from pathlib import WindowsPath
 from typing import Any
-from typing import Iterable
+from typing import MutableMapping
 from urllib.parse import SplitResult
 
-from fsspec.implementations.local import LocalFileSystem
-
+from upath._flavour import FSSpecFlavour
 from upath.core import UPath
 
 __all__ = [
@@ -24,166 +23,136 @@
 
 class LocalPath(UPath):
     __slots__ = ()
+    _flavour = FSSpecFlavour(
+        posixpath_only=False,
+    )
 
+    @property
+    def path(self):
+        sep = self._flavour.sep
+        if self.drive:
+            return f"/{super().path}".replace(sep, "/")
+        return super().path.replace(sep, "/")
 
-class FilePath(LocalPath):
-    __slots__ = ()
+    @property
+    def _url(self):
+        return SplitResult(self.protocol, "", self.path, "", "")
 
 
-_PY310_IGNORE = {"__slots__", "__module__", "_from_parts", "__new__"}
+class FilePath(LocalPath):
+    __slots__ = ()
 
 
-def _iterate_class_attrs(
-    path_cls: type[Path],
-    ignore: set[str] = frozenset(),
-) -> Iterable[tuple[str, Any]]:
+_pathlib_py312_ignore = {
+    "__slots__",
+    "__module__",
+    "__new__",
+    "__init__",
+    "_from_parts",
+    "_from_parsed_parts",
+    "with_segments",
+}
+
+
+def _set_class_attributes(
+    type_dict: MutableMapping[str, Any],
+    src: type[Path],
+    *,
+    ignore: set[str] = frozenset(_pathlib_py312_ignore),
+) -> None:
+    """helper function to assign all methods/attrs from src to a class dict"""
     visited = set()
-    for cls in path_cls.__mro__:
+    for cls in src.__mro__:
         if cls is object:
             continue
         for attr, func_or_value in cls.__dict__.items():
-            if attr in ignore:
-                continue
-            if attr in visited:
-                continue
             if ismemberdescriptor(func_or_value):
                 continue
+            if attr in ignore or attr in visited:
+                continue
+            else:
+                visited.add(attr)
 
-            yield attr, func_or_value
-            visited.add(attr)
+            type_dict[attr] = func_or_value
 
 
 class PosixUPath(PosixPath, LocalPath):
     __slots__ = ()
 
-    if os.name == "nt":
-        __new__ = PosixPath.__new__  # type: ignore
-
     # assign all PosixPath methods/attrs to prevent multi inheritance issues
-    for attr, func_or_attr in _iterate_class_attrs(PosixPath, ignore=_PY310_IGNORE):
-        locals()[attr] = func_or_attr
-    del attr, func_or_attr
+    _set_class_attributes(locals(), src=PosixPath)
+
+    if sys.version_info < (3, 12):
+
+        def __new__(
+            cls, *args, protocol: str | None = None, **storage_options: Any
+        ) -> UPath:
+            obj = super().__new__(cls, *args)
+            obj._protocol = ""
+            return obj
+
+        def __init__(
+            self, *args, protocol: str | None = None, **storage_options: Any
+        ) -> None:
+            super(Path, self).__init__()
+            self._drv, self._root, self._parts = type(self)._parse_args(args)
+            self._storage_options = {}
+
+        @classmethod
+        def _from_parts(cls, args, **kwargs):
+            obj = super(Path, cls)._from_parts(cls, args, **kwargs)
+            obj._protocol = ""
+            obj._storage_options = {}
+            return obj
+
+        @classmethod
+        def _from_parsed_parts(cls, drv, root, parts):
+            obj = super(Path, cls)._from_parsed_parts(drv, root, parts)
+            obj._protocol = ""
+            obj._storage_options = {}
+            return obj
 
-    @property
-    def fs(self):
-        return LocalFileSystem()
-
-    @property
-    def path(self) -> str:
-        return str(self)
-
-    @classmethod
-    def _from_parts(cls, args, *, url=None, **kw):
-        obj = super(UPath, cls)._from_parts(args)
-        obj._kwargs = {}
-        obj._url = SplitResult("", "", str(obj), "", "")
-        return obj
-
-    @classmethod
-    def _from_parsed_parts(
-        cls,
-        drv,
-        root,
-        parts,
-        url=None,
-        **kwargs: Any,
-    ):
-        obj = super(UPath, cls)._from_parsed_parts(  # type: ignore[misc]
-            drv, root, parts
-        )
-        obj._kwargs = {}
-        obj._url = SplitResult("", "", str(obj), "", "")
-        return obj
+        @property
+        def path(self) -> str:
+            return PosixPath.__str__(self)
 
 
 class WindowsUPath(WindowsPath, LocalPath):
     __slots__ = ()
 
-    if os.name != "nt":
-        __new__ = WindowsPath.__new__  # type: ignore
-
     # assign all WindowsPath methods/attrs to prevent multi inheritance issues
-    for attr, func_or_attr in _iterate_class_attrs(WindowsPath, ignore=_PY310_IGNORE):
-        locals()[attr] = func_or_attr
-    del attr, func_or_attr
-
-    @property
-    def fs(self):
-        return LocalFileSystem()
-
-    @property
-    def path(self) -> str:
-        return str(self)
-
-    @classmethod
-    def _from_parts(cls, args, *, url=None, **kw):
-        obj = super(UPath, cls)._from_parts(args)
-        obj._kwargs = {}
-        obj._url = SplitResult("", "", str(obj), "", "")
-        return obj
-
-    @classmethod
-    def _from_parsed_parts(
-        cls,
-        drv,
-        root,
-        parts,
-        url=None,
-        **kwargs: Any,
-    ):
-        obj = super(UPath, cls)._from_parsed_parts(  # type: ignore[misc]
-            drv, root, parts
-        )
-        obj._kwargs = {}
-        obj._url = SplitResult("", "", str(obj), "", "")
-        return obj
-
-
-if sys.version_info >= (3, 12):  # noqa: C901
-    from upath.core312plus import FSSpecFlavour
-
-    class LocalPath(UPath):
-        __slots__ = ()
-        _flavour = FSSpecFlavour(
-            posixpath_only=False,
-        )
-
-        @property
-        def path(self):
-            sep = self._flavour.sep
-            if self.drive:
-                return f"/{super().path}".replace(sep, "/")
-            return super().path.replace(sep, "/")
+    _set_class_attributes(locals(), src=WindowsPath)
+
+    if sys.version_info < (3, 12):
+
+        def __new__(
+            cls, *args, protocol: str | None = None, **storage_options: Any
+        ) -> UPath:
+            obj = super(pathlib.Path).__new__(cls, *args)
+            obj._protocol = ""
+            return obj
+
+        def __init__(
+            self, *args, protocol: str | None = None, **storage_options: Any
+        ) -> None:
+            super(Path, self).__init__(*args)
+            self._drv, self._root, self._parts = self._parse_args(args)
+            self._storage_options = {}
+
+        @classmethod
+        def _from_parts(cls, args, **kwargs):
+            obj = super(Path, cls)._from_parts(cls, args, **kwargs)
+            obj._protocol = ""
+            obj._storage_options = {}
+            return obj
+
+        @classmethod
+        def _from_parsed_parts(cls, drv, root, parts):
+            obj = super(Path, cls)._from_parsed_parts(drv, root, parts)
+            obj._protocol = ""
+            obj._storage_options = {}
+            return obj
 
         @property
-        def _url(self):
-            return SplitResult(self.protocol, "", self.path, "", "")
-
-    class FilePath(LocalPath):  # noqa
-        __slots__ = ()
-
-    _PY312_IGNORE = {"__slots__", "__module__", "__new__", "__init__", "with_segments"}
-
-    class PosixUPath(PosixPath, LocalPath):  # noqa
-        __slots__ = ()
-
-        if os.name == "nt":
-            __new__ = PosixPath.__new__
-
-        # assign all PosixPath methods/attrs to prevent multi inheritance issues
-        for attr, func_or_attr in _iterate_class_attrs(PosixPath, ignore=_PY312_IGNORE):
-            locals()[attr] = func_or_attr
-        del attr, func_or_attr
-
-    class WindowsUPath(WindowsPath, LocalPath):  # noqa
-        __slots__ = ()
-
-        if os.name != "nt":
-            __new__ = WindowsPath.__new__
-
-        # assign all WindowsPath methods/attrs to prevent multi inheritance issues
-        for attr, func_or_attr in _iterate_class_attrs(
-            WindowsPath, ignore=_PY312_IGNORE
-        ):
-            locals()[attr] = func_or_attr
-        del attr, func_or_attr
+        def path(self) -> str:
+            return WindowsPath.__str__(self)
diff --git a/upath/implementations/memory.py b/upath/implementations/memory.py
index 09e564a7..b5fbac85 100644
--- a/upath/implementations/memory.py
+++ b/upath/implementations/memory.py
@@ -1,79 +1,21 @@
 from __future__ import annotations
 
-import sys
-from typing import Any
-from urllib.parse import SplitResult
-
 import upath.core
 
 
-class _MemoryAccessor(upath.core._FSSpecAccessor):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self._fs.root_marker = ""
-
-
-class MemoryPath(upath.core.UPath):
-    _default_accessor = _MemoryAccessor
-
+class MemoryPath(upath.core.UPath):  # noqa
     def iterdir(self):
-        """Iterate over the files in this directory.  Does not yield any
-        result for the special paths '.' and '..'.
-        """
-        for name in self._accessor.listdir(self):
-            # fsspec returns dictionaries
-            if isinstance(name, dict):
-                name = name.get("name")
-            if name in {".", ".."}:
-                # Yielding a path object for these makes little sense
-                continue
-            # only want the path name with iterdir
-            name = name.rstrip("/")
-            name = self._sub_path(name)
-            yield self._make_child_relpath(name)
+        if not self.is_dir():
+            raise NotADirectoryError(str(self))
+        yield from super().iterdir()
 
-    @classmethod
-    def _from_parts(cls, args, url=None, **kwargs):
-        if url and url.netloc:
-            if args:
-                if args[0].startswith("/"):
-                    args[0] = args[0][1:]
-                args[0:1] = [f"/{url.netloc}/{args[0]}"]
-            else:
-                args[:] = f"/{url.netloc}"
-            url = url._replace(netloc="")
-        return super()._from_parts(args, url=url, **kwargs)
+    @property
+    def path(self):
+        path = super().path
+        return "/" if path == "." else path
 
-    @classmethod
-    def _format_parsed_parts(
-        cls,
-        drv: str,
-        root: str,
-        parts: list[str],
-        url: SplitResult | None = None,
-        **kwargs: Any,
-    ) -> str:
-        s = super()._format_parsed_parts(drv, root, parts, url=url, **kwargs)
+    def __str__(self):
+        s = super().__str__()
         if s.startswith("memory:///"):
             s = s.replace("memory:///", "memory://", 1)
         return s
-
-
-if sys.version_info >= (3, 12):
-
-    class MemoryPath(upath.core.UPath):  # noqa
-        def iterdir(self):
-            if not self.is_dir():
-                raise NotADirectoryError(str(self))
-            yield from super().iterdir()
-
-        @property
-        def path(self):
-            path = super().path
-            return "/" if path == "." else path
-
-        def __str__(self):
-            s = super().__str__()
-            if s.startswith("memory:///"):
-                s = s.replace("memory:///", "memory://", 1)
-            return s
diff --git a/upath/implementations/webdav.py b/upath/implementations/webdav.py
index ff1f1225..aa953f90 100644
--- a/upath/implementations/webdav.py
+++ b/upath/implementations/webdav.py
@@ -1,114 +1,49 @@
 from __future__ import annotations
 
-import sys
 from typing import Any
-from urllib.parse import ParseResult
 from urllib.parse import urlsplit
 from urllib.parse import urlunsplit
 
 import upath.core
-
-
-class _WebdavAccessor(upath.core._FSSpecAccessor):
-    def __init__(self, parsed_url: ParseResult, **kwargs):
-        from webdav4.fsspec import WebdavFileSystem
-
-        parsed_url = parsed_url._replace(scheme=parsed_url.scheme[7:], path="")
-        base_url = urlunsplit(parsed_url)
-        self._fs = WebdavFileSystem(base_url=base_url, **kwargs)
-
-    def listdir(self, path, **kwargs):
-        base_url = urlunsplit(path._url._replace(path=""))
-        for file_info in self._fs.listdir(
-            self._format_path(path).lstrip("/"), **kwargs
-        ):
-            yield {
-                **file_info,
-                "name": f"{base_url}/{file_info['name']}",
-            }
-
-    def glob(self, path, path_pattern, **kwargs):
-        base_url = urlunsplit(path._url._replace(path=""))
-        for file_path in self._fs.glob(
-            self._format_path(path_pattern).lstrip("/"), **kwargs
-        ):
-            yield f"{base_url}/{file_path}"
+from upath._compat import str_remove_prefix
+from upath._compat import str_remove_suffix
 
 
 class WebdavPath(upath.core.UPath):
-    _default_accessor = _WebdavAccessor
-
-    def _sub_path(self, name):
-        """fsspec returns path as `scheme://netloc/<path>` with listdir
-        and glob, so we potentially need to sub the whole string
-        """
-        sp = self.path
-        complete_address = self._format_parsed_parts(
-            None, None, [sp], url=self._url, **self._kwargs
-        )
-
-        if name.startswith(complete_address):
-            name = name[len(complete_address) :]  # noqa: E203
-        name = name.strip("/")
-
-        return name
-
-    @property
-    def protocol(self) -> str:
-        if self._url is None:
-            raise RuntimeError(str(self))
-        return self._url.scheme.split("+")[0]
-
-    @property
-    def storage_options(self) -> dict[str, Any]:
-        if self._url is None:
-            raise RuntimeError(str(self))
-        sopts = super().storage_options
-        http_protocol = self._url.scheme.split("+")[1]
-        assert http_protocol in {"http", "https"}
-        base_url = urlunsplit(self._url._replace(scheme=http_protocol, path=""))
-        sopts["base_url"] = base_url
-        return sopts
-
-
-if sys.version_info >= (3, 12):
-    import upath.core312plus
-
-    class WebdavPath(upath.core312plus.UPath):  # noqa
-        __slots__ = ()
-
-        def __init__(
-            self, *args, protocol: str | None = None, **storage_options: Any
-        ) -> None:
-            base_options = getattr(self, "_storage_options", {})  # when unpickling
-            if args:
-                args0, *argsN = args
-                url = urlsplit(str(args0))
-                args0 = urlunsplit(url._replace(scheme="", netloc="")) or "/"
-                if "base_url" not in storage_options:
-                    if self._protocol == "webdav+http":
-                        storage_options["base_url"] = urlunsplit(
-                            url._replace(scheme="http", path="")
-                        )
-                    elif self._protocol == "webdav+https":
-                        storage_options["base_url"] = urlunsplit(
-                            url._replace(scheme="https", path="")
-                        )
-            else:
-                args0, argsN = "/", ()
-            storage_options = {**base_options, **storage_options}
+    __slots__ = ()
+
+    def __init__(
+        self, *args, protocol: str | None = None, **storage_options: Any
+    ) -> None:
+        base_options = getattr(self, "_storage_options", {})  # when unpickling
+        if args:
+            args0, *argsN = args
+            url = urlsplit(str(args0))
+            args0 = urlunsplit(url._replace(scheme="", netloc="")) or "/"
             if "base_url" not in storage_options:
-                raise ValueError(
-                    f"must provide `base_url` storage option for args: {args!r}"
-                )
-            self._protocol = "webdav"
-            super().__init__(args0, *argsN, protocol="webdav", **storage_options)
+                if self._protocol == "webdav+http":
+                    storage_options["base_url"] = urlunsplit(
+                        url._replace(scheme="http", path="")
+                    )
+                elif self._protocol == "webdav+https":
+                    storage_options["base_url"] = urlunsplit(
+                        url._replace(scheme="https", path="")
+                    )
+        else:
+            args0, argsN = "/", ()
+        storage_options = {**base_options, **storage_options}
+        if "base_url" not in storage_options:
+            raise ValueError(
+                f"must provide `base_url` storage option for args: {args!r}"
+            )
+        self._protocol = "webdav"
+        super().__init__(args0, *argsN, protocol="webdav", **storage_options)
 
-        @property
-        def path(self) -> str:
-            # webdav paths don't start at "/"
-            return super().path.removeprefix("/")
+    @property
+    def path(self) -> str:
+        # webdav paths don't start at "/"
+        return str_remove_prefix(super().path, "/")
 
-        def __str__(self):
-            base_url = self.storage_options["base_url"].removesuffix("/")
-            return super().__str__().replace("webdav://", f"webdav+{base_url}", 1)
+    def __str__(self):
+        base_url = str_remove_suffix(self.storage_options["base_url"], "/")
+        return super().__str__().replace("webdav://", f"webdav+{base_url}", 1)

From 48ff8916fb292d767110f6b16d835303e726ead5 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 03:00:19 +0100
Subject: [PATCH 17/50] upath: provide single UPath implementation

---
 upath/__init__.py    |   10 +-
 upath/core.py        | 1161 +++++++++++++++---------------------------
 upath/core312plus.py |  562 --------------------
 3 files changed, 407 insertions(+), 1326 deletions(-)
 delete mode 100644 upath/core312plus.py

diff --git a/upath/__init__.py b/upath/__init__.py
index f5ec5279..3392f65f 100644
--- a/upath/__init__.py
+++ b/upath/__init__.py
@@ -6,14 +6,6 @@
 except ImportError:
     __version__ = "not-installed"
 
-if sys.version_info >= (3, 12):
-    import upath.core312plus as core
-
-    sys.modules["upath.core"] = core
-
-else:
-    import upath.core as core
-
-UPath = core.UPath
+from upath.core import UPath
 
 __all__ = ["UPath"]
diff --git a/upath/core.py b/upath/core.py
index e487fe04..e3be4ad1 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -1,298 +1,242 @@
 from __future__ import annotations
 
-import re
+import os
 import sys
-from os import PathLike
+import warnings
+from copy import copy
 from pathlib import Path
-from pathlib import PurePath
-from pathlib import _PosixFlavour  # type: ignore
+from types import MappingProxyType
 from typing import TYPE_CHECKING
-from typing import Callable
+from typing import Any
 from typing import Mapping
-from typing import Sequence
-from typing import TypeVar
+from typing import cast
+from urllib.parse import SplitResult
 from urllib.parse import urlsplit
-from urllib.parse import urlunsplit
 
-from fsspec.registry import get_filesystem_class
-from fsspec.utils import stringify_path
+if sys.version_info >= (3, 11):
+    from typing import Self
+else:
+    Self = Any
 
+from fsspec import AbstractFileSystem
+from fsspec import get_filesystem_class
+
+from upath._compat import PathlibPathShim
+from upath._compat import str_remove_prefix
+from upath._compat import str_remove_suffix
+from upath._flavour import FSSpecFlavour
 from upath._protocol import get_upath_protocol
-from upath._protocol import strip_upath_protocol
 from upath.registry import get_upath_class
 
-if TYPE_CHECKING:
-    from typing import Any
-    from typing import Generator
-    from urllib.parse import SplitResult
-
-    from fsspec.spec import AbstractFileSystem
-
-__all__ = [
-    "UPath",
-]
-
-_FSSPEC_HAS_WORKING_GLOB = None
-
-
-def _check_fsspec_has_working_glob():
-    global _FSSPEC_HAS_WORKING_GLOB
-    from fsspec.implementations.memory import MemoryFileSystem
-
-    m = type("_M", (MemoryFileSystem,), {"store": {}, "pseudo_dirs": [""]})()
-    m.touch("a.txt")
-    m.touch("f/b.txt")
-    g = _FSSPEC_HAS_WORKING_GLOB = len(m.glob("**/*.txt")) == 2
-    return g
-
 
 class _FSSpecAccessor:
-    __slots__ = ("_fs",)
+    """this is a compatibility shim and will be removed"""
 
     def __init__(self, parsed_url: SplitResult | None, **kwargs: Any) -> None:
-        if parsed_url and parsed_url.scheme:
-            cls = get_filesystem_class(parsed_url.scheme)
-            url_kwargs = cls._get_kwargs_from_urls(urlunsplit(parsed_url))
-        else:
-            cls = get_filesystem_class(None)
-            url_kwargs = {}
-        url_kwargs.update(kwargs)
-        self._fs = cls(**url_kwargs)
-
-    def _format_path(self, path: UPath) -> str:
-        return path._path
-
-    def open(self, path, mode="r", *args, **kwargs):
-        return self._fs.open(self._format_path(path), mode, *args, **kwargs)
-
-    def stat(self, path, **kwargs):
-        return self._fs.stat(self._format_path(path), **kwargs)
+        pass
 
-    def listdir(self, path, **kwargs):
-        p_fmt = self._format_path(path)
-        contents = self._fs.listdir(p_fmt, **kwargs)
-        if len(contents) == 0 and not self._fs.isdir(p_fmt):
-            raise NotADirectoryError(str(self))
-        elif (
-            len(contents) == 1
-            and contents[0]["name"] == p_fmt
-            and contents[0]["type"] == "file"
-        ):
-            raise NotADirectoryError(str(self))
-        return contents
 
-    def glob(self, _path, path_pattern, **kwargs):
-        return self._fs.glob(self._format_path(path_pattern), **kwargs)
+def _make_instance(cls, args, kwargs):
+    """helper for pickling UPath instances"""
+    return cls(*args, **kwargs)
 
-    def exists(self, path, **kwargs):
-        return self._fs.exists(self._format_path(path), **kwargs)
 
-    def info(self, path, **kwargs):
-        return self._fs.info(self._format_path(path), **kwargs)
-
-    def rm(self, path, recursive, **kwargs):
-        return self._fs.rm(self._format_path(path), recursive=recursive, **kwargs)
+class UPath(PathlibPathShim, Path):
+    __slots__ = (
+        "_protocol",
+        "_storage_options",
+        "_fs_cached",
+        *PathlibPathShim.__missing_py312_slots__,
+    )
+    if TYPE_CHECKING:
+        _protocol: str
+        _storage_options: dict[str, Any]
+        _fs_cached: AbstractFileSystem
+
+    _flavour = FSSpecFlavour()
+
+    def __new__(
+        cls, *args, protocol: str | None = None, **storage_options: Any
+    ) -> UPath:
+        # fill empty arguments
+        if not args:
+            args = (".",)
+
+        # create a copy if UPath class
+        part0, *parts = args
+        if not parts and not storage_options and isinstance(part0, cls):
+            return copy(part0)
+
+        # deprecate 'scheme'
+        if "scheme" in storage_options:
+            warnings.warn(
+                "use 'protocol' kwarg instead of 'scheme'",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            protocol = storage_options.pop("scheme")
 
-    def mkdir(self, path, create_parents=True, **kwargs):
-        return self._fs.mkdir(
-            self._format_path(path), create_parents=create_parents, **kwargs
+        # determine which UPath subclass to dispatch to
+        pth_protocol = get_upath_protocol(
+            part0, protocol=protocol, storage_options=storage_options
         )
+        upath_cls = get_upath_class(protocol=pth_protocol)
+        if upath_cls is None:
+            raise ValueError(f"Unsupported filesystem: {pth_protocol!r}")
 
-    def makedirs(self, path, exist_ok=False, **kwargs):
-        return self._fs.makedirs(self._format_path(path), exist_ok=exist_ok, **kwargs)
+        # create a new instance
+        if cls is UPath:
+            # we called UPath() directly, and want an instance based on the
+            # provided or detected protocol (i.e. upath_cls)
+            obj: UPath = cast("UPath", object.__new__(upath_cls))
+            obj._protocol = pth_protocol
+
+        elif issubclass(cls, upath_cls):
+            # we called a sub- or sub-sub-class of UPath, i.e. S3Path() and the
+            # corresponding upath_cls based on protocol is equal-to or a
+            # parent-of the cls.
+            obj = cast("UPath", object.__new__(cls))  # type: ignore[unreachable]
+            obj._protocol = pth_protocol
+
+        elif issubclass(cls, UPath):
+            # we called a subclass of UPath directly, i.e. S3Path() but the
+            # detected protocol would return a non-related UPath subclass, i.e.
+            # S3Path("file:///abc"). This behavior is going to raise an error
+            # in future versions
+            msg_protocol = repr(pth_protocol)
+            if not pth_protocol:
+                msg_protocol += " (empty string)"
+            msg = (
+                f"{cls.__name__!s}(...) detected protocol {msg_protocol!s} and"
+                f" returns a {upath_cls.__name__} instance that isn't a direct"
+                f" subclass of {cls.__name__}. This will raise an exception in"
+                " future universal_pathlib versions. To prevent the issue, use"
+                " UPath(...) to create instances of unrelated protocols or you"
+                f" can instead derive your subclass {cls.__name__!s}(...) from"
+                f" {upath_cls.__name__} or alternatively override behavior via"
+                f" registering the {cls.__name__} implementation with protocol"
+                f" {msg_protocol!s} replacing the default implementation."
+            )
+            warnings.warn(msg, DeprecationWarning, stacklevel=2)
 
-    def touch(self, path, **kwargs):
-        return self._fs.touch(self._format_path(path), **kwargs)
+            obj = cast("UPath", object.__new__(upath_cls))
+            obj._protocol = pth_protocol
 
-    def mv(self, path, target, recursive=False, maxdepth=None, **kwargs):
-        if hasattr(target, "_accessor"):
-            target = target._accessor._format_path(target)
-        return self._fs.mv(
-            self._format_path(path),
-            target,
-            recursive=recursive,
-            maxdepth=maxdepth,
-            **kwargs,
-        )
+            upath_cls.__init__(
+                obj, *args, protocol=pth_protocol, **storage_options
+            )  # type: ignore
 
+        else:
+            raise RuntimeError("UPath.__new__ expected cls to be subclass of UPath")
 
-def _patch_accessor(
-    accessor_cls: type[_FSSpecAccessor],
-    fs_factory: Callable[[str, str, Mapping[str, Any]], AbstractFileSystem],
-) -> Callable[..., _FSSpecAccessor]:
-    """Patch the _FSSpecAccessor class to support `UPath._fs_factory` overrides"""
+        return obj
 
-    def _patch_init(
-        self,
-        parsed_url: SplitResult | None,
-        **kwargs: Any,
+    def __init__(
+        self, *args, protocol: str | None = None, **storage_options: Any
     ) -> None:
-        self._fs = self._fs_factory(
-            parsed_url.geturl(), parsed_url.scheme or "", kwargs
+        # allow subclasses to customize __init__ arg parsing
+        base_options = getattr(self, "_storage_options", {})
+        args, protocol, storage_options = type(self)._transform_init_args(
+            args, protocol, {**base_options, **storage_options}
         )
-
-    return type(
-        f"Patched{accessor_cls.__name__}",
-        (accessor_cls,),
-        {
-            "__init__": _patch_init,
-            "_fs_factory": staticmethod(fs_factory),
-        },
-    )
-
-
-class _UriFlavour(_PosixFlavour):
-    def parse_parts(self, parts):
-        parsed = []
-        sep = self.sep
-        drv = root = ""
-        it = reversed(parts)
-        for part in it:
-            if part:
-                drv, root, rel = self.splitroot(part)
-                if not root or root and rel:
-                    for x in reversed(rel.split(sep)):
-                        parsed.append(sys.intern(x))
-
-        if drv or root:
-            parsed.append(drv + root)
-        parsed.reverse()
-        return drv, root, parsed
-
-    def splitroot(self, part, sep="/"):
-        # Treat the first slash in the path as the root if it exists
-        if part and part[0] == sep:
-            return "", sep, part[1:]
-        return "", "", part
-
-
-PT = TypeVar("PT", bound="UPath")
-
-
-class UPath(Path):
-    __slots__ = (
-        "_url",
-        "_kwargs",
-        "_accessor",  # overwritten because of default in Python 3.10
-    )
-    _flavour = _UriFlavour()
-    _default_accessor = _FSSpecAccessor
-
-    # typing
-    _drv: str
-    _root: str
-    _str: str
-    _url: SplitResult | None
-    _parts: list[str]
-    _closed: bool
-    _accessor: _FSSpecAccessor
-
-    def __new__(cls: type[PT], *args: str | PathLike, **kwargs: Any) -> PT:
-        args_list = list(args)
-        try:
-            other = args_list.pop(0)
-        except IndexError:
-            other = "."
-        else:
-            other = other or "."
-
-        if isinstance(other, PurePath):
-            # Create a (modified) copy, if first arg is a Path object
-            _cls: type[Any] = type(other)
-            drv, root, parts = _cls._parse_args(args_list)
-            drv, root, parts = _cls._flavour.join_parsed_parts(
-                other._drv, other._root, other._parts, drv, root, parts  # type: ignore # noqa: E501
-            )
-
-            _kwargs = getattr(other, "_kwargs", {})
-            _url = getattr(other, "_url", None)
-            other_kwargs = _kwargs.copy()
-            if _url and _url.scheme:
-                other_kwargs["url"] = _url
-            new_kwargs = _kwargs.copy()
-            new_kwargs.update(kwargs)
-
-            return _cls(
-                _cls._format_parsed_parts(drv, root, parts, **other_kwargs),
-                **new_kwargs,
-            )
-
-        url = stringify_path(other)
-        protocol = get_upath_protocol(url)
-        parsed_url = urlsplit(url)
-
-        if protocol is None and ":/" in url[2:]:  # excludes windows paths: C:/...
-            protocol = kwargs.get("scheme", parsed_url.scheme) or ""
+        if self._protocol != protocol and protocol:
+            self._protocol = protocol
+
+        # retrieve storage_options
+        if args:
+            args0 = args[0]
+            if isinstance(args0, UPath):
+                self._storage_options = {**args0.storage_options, **storage_options}
+            else:
+                fs_cls: type[AbstractFileSystem] = get_filesystem_class(
+                    protocol or self._protocol
+                )
+                pth_storage_options = fs_cls._get_kwargs_from_urls(str(args0))
+                self._storage_options = {**pth_storage_options, **storage_options}
         else:
-            protocol = kwargs.get("scheme", protocol) or ""
-
-        upath_cls = get_upath_class(protocol=protocol)
-        if upath_cls is None:
-            raise ValueError(f"Unsupported filesystem: {parsed_url.scheme!r}")
+            self._storage_options = storage_options.copy()
 
-        for key in ["scheme", "netloc"]:
-            val = kwargs.get(key)
-            if val:
-                parsed_url = parsed_url._replace(**{key: val})
+        # check that UPath subclasses in args are compatible
+        # --> ensures items in _raw_paths are compatible
+        for arg in args:
+            if not isinstance(arg, UPath):
+                continue
+            # protocols: only identical (or empty "") protocols can combine
+            if arg.protocol and arg.protocol != self._protocol:
+                raise TypeError("can't combine different UPath protocols as parts")
+            # storage_options: args may not define other storage_options
+            if any(
+                self._storage_options.get(key) != value
+                for key, value in arg.storage_options.items()
+            ):
+                # raise ValueError(
+                #     "can't combine different UPath storage_options as parts"
+                # ) todo: revisit and define behaviour
+                pass
 
-        if not parsed_url.path:
-            parsed_url = parsed_url._replace(path="/")  # ensure path has root
+        # fill ._raw_paths
+        super().__init__(*args)
 
-        if not protocol:
-            args_list.insert(0, url)
-        else:
-            args_list.insert(0, strip_upath_protocol(url))
+    # === upath.UPath only ============================================
 
-        return upath_cls._from_parts(  # type: ignore
-            args_list, url=parsed_url, **kwargs
-        )
+    @classmethod
+    def _transform_init_args(
+        cls,
+        args: tuple[str | os.PathLike, ...],
+        protocol: str,
+        storage_options: dict[str, Any],
+    ) -> tuple[tuple[str | os.PathLike, ...], str, dict[str, Any]]:
+        """allow customization of init args in subclasses"""
+        return args, protocol, storage_options
 
     @property
     def protocol(self) -> str:
-        """The filesystem_spec protocol
-
-        For local paths protocol is either 'file' if the UPath instance
-        is backed by fsspec or '' if it's backed by stdlib pathlib. For
-        both `fsspec.get_filesystem_class` returns `LocalFileSystem`.
-        """
-        if self._url is None:
-            return ""
-        return self._url.scheme
+        return self._protocol
 
     @property
-    def storage_options(self) -> dict[str, Any]:
-        """The filesystem_spec storage options dictionary
-
-        Accessing `.storage_options` does not instantiate the
-        corresponding fsspec filesystem class.
-        """
-        return {
-            key: value
-            for key, value in self._kwargs.items()
-            if key not in {"scheme", "netloc", "url"}
-        }
+    def storage_options(self) -> Mapping[str, Any]:
+        return MappingProxyType(self._storage_options)
 
     def __init_subclass__(cls, **kwargs):
         """provide a clean migration path for custom user subclasses"""
 
-        has_custom_fs_factory = (
-            cls._fs_factory.__func__ is not UPath._fs_factory.__func__
+        accessor_cls = getattr(cls, "_default_accessor", None)
+
+        # guess which parts the user subclass is customizing
+        has_custom_legacy_accessor = (
+            accessor_cls is not None
+            and issubclass(accessor_cls, _FSSpecAccessor)
+            and accessor_cls is not _FSSpecAccessor
         )
-        is_patched = (
-            hasattr(cls._default_accessor, "_fs_factory")
-            and cls._default_accessor._fs_factory.__func__
-            is not cls._fs_factory.__func__
+        has_customized_fs_instantiation = (
+            accessor_cls.__init__ is not _FSSpecAccessor.__init__
+            or hasattr(accessor_cls, "_fs")
         )
 
-        if has_custom_fs_factory and not is_patched:
-            cls._default_accessor = _patch_accessor(
-                accessor_cls=cls._default_accessor,
-                fs_factory=cls._fs_factory,
+        if has_custom_legacy_accessor and has_customized_fs_instantiation:
+            warnings.warn(
+                "Detected a customized `__init__` method or `_fs` attribute"
+                " in the provided `_FSSpecAccessor` subclass. It is recommended to"
+                " instead override the `_fs_factory` classmethod to customize"
+                " filesystem instantiation.",
+                DeprecationWarning,
+                stacklevel=2,
             )
 
+            def _fs_factory(
+                cls_, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
+            ) -> AbstractFileSystem:
+                url = urlsplit(urlpath)
+                if protocol:
+                    url = url._replace(scheme=protocol)
+                inst = cls_._default_accessor(url, **storage_options)
+                return inst._fs
+
+            cls._fs_factory = classmethod(_fs_factory)
+
     @classmethod
     def _fs_factory(
-        cls, urlpath: str, protocol: str | None, storage_options: Mapping[str, Any]
+        cls, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
     ) -> AbstractFileSystem:
         """Instantiate the filesystem_spec filesystem class"""
         fs_cls = get_filesystem_class(protocol)
@@ -302,319 +246,208 @@ def _fs_factory(
 
     @property
     def fs(self) -> AbstractFileSystem:
-        """The filesystem_spec filesystem instance"""
-        return self._accessor._fs
+        try:
+            return self._fs_cached
+        except AttributeError:
+            fs = self._fs_cached = self._fs_factory(
+                str(self), self.protocol, self.storage_options
+            )
+            return fs
 
     @property
     def path(self) -> str:
-        """The filesystem_spec path for use with a filesystem instance
-
-        Note: for some file systems this can be prefixed by the protocol.
-        """
-        return self._path
-
-    def __getattr__(self, item: str) -> Any:
-        if item == "_accessor":
-            # cache the _accessor attribute on first access
-            kwargs = self._kwargs.copy()
-            self._accessor = _accessor = self._default_accessor(self._url, **kwargs)
-            return _accessor
-        else:
-            raise AttributeError(item)
+        return super().__str__()
 
-    def _make_child(self: PT, args: list[str]) -> PT:
-        drv, root, parts = self._parse_args(args)
-        drv, root, parts = self._flavour.join_parsed_parts(
-            self._drv, self._root, self._parts, drv, root, parts
-        )
-        return self._from_parsed_parts(drv, root, parts, url=self._url, **self._kwargs)
-
-    def _make_child_relpath(self: PT, part: str) -> PT:
-        # This is an optimization used for dir walking.  `part` must be
-        # a single part relative to this path.
-        if self._parts[-1:] == [""] and part:
-            parts = self._parts[:-1] + [part]
-        else:
-            parts = self._parts + [part]
-        return self._from_parsed_parts(
-            self._drv, self._root, parts, url=self._url, **self._kwargs
+    @property
+    def _kwargs(self):
+        warnings.warn(
+            "use UPath.storage_options instead of UPath._kwargs",
+            DeprecationWarning,
+            stacklevel=2,
         )
-
-    @classmethod
-    def _format_parsed_parts(
-        cls: type[PT],
-        drv: str,
-        root: str,
-        parts: list[str],
-        url: SplitResult | None = None,
-        **kwargs: Any,
-    ) -> str:
-        if parts:
-            join_parts = parts[1:] if parts[0] == "/" else parts
-        else:
-            join_parts = []
-        if drv or root:
-            path: str = drv + root + cls._flavour.join(join_parts)
-        else:
-            path = cls._flavour.join(join_parts)
-        if not url:
-            scheme: str = kwargs.get("scheme", "file")
-            netloc: str = kwargs.get("netloc", "")
-        else:
-            scheme, netloc = url.scheme, url.netloc
-        scheme = (scheme + ":") if scheme else ""
-        netloc = "//" + netloc  # always add netloc
-        formatted = scheme + netloc + path
-        return formatted
+        return self.storage_options
 
     @property
-    def _path(self) -> str:
-        if self._parts:
-            join_parts = self._parts[1:] if self._parts[0] == "/" else self._parts
-            path: str = self._flavour.join(join_parts)
-            return self._root + path
-        else:
-            return "/"
-
-    def open(self, *args, **kwargs):
-        return self._accessor.open(self, *args, **kwargs)
+    def _url(self):  # todo: deprecate
+        return urlsplit(self.as_posix())
 
-    @property
-    def parent(self: PT) -> PT:
-        """The logical parent of the path."""
-        drv = self._drv
-        root = self._root
-        parts = self._parts
-        if len(parts) == 1 and (drv or root):
-            return self
-        return self._from_parsed_parts(
-            drv, root, parts[:-1], url=self._url, **self._kwargs
-        )
+    # === pathlib.PurePath ============================================
 
-    def stat(self):
-        return self._accessor.stat(self)
+    def __reduce__(self):
+        args = tuple(self._raw_paths)
+        kwargs = {
+            "protocol": self._protocol,
+            **self._storage_options,
+        }
+        return _make_instance, (type(self), args, kwargs)
 
-    def samefile(self, other_path) -> bool:
-        raise NotImplementedError
+    def with_segments(self, *pathsegments):
+        return type(self)(
+            *pathsegments,
+            protocol=self._protocol,
+            **self._storage_options,
+        )
 
-    def iterdir(self: PT) -> Generator[PT, None, None]:
-        """Iterate over the files in this directory.  Does not yield any
-        result for the special paths '.' and '..'.
-        """
-        for name in self._accessor.listdir(self):
-            # fsspec returns dictionaries
-            if isinstance(name, dict):
-                name = name.get("name")
-            if name in {".", ".."}:
-                # Yielding a path object for these makes little sense
-                continue
-            # only want the path name with iterdir
-            name = self._sub_path(name)
-            yield self._make_child_relpath(name)
+    @classmethod
+    def _parse_path(cls, path):
+        if getattr(cls._flavour, "supports_empty_parts", False):
+            drv, root, rel = cls._flavour.splitroot(path)
+            if not root:
+                parsed = []
+            else:
+                parsed = list(map(sys.intern, rel.split(cls._flavour.sep)))
+                if parsed[-1] == ".":
+                    parsed[-1] = ""
+                parsed = [x for x in parsed if x != "."]
+            return drv, root, parsed
+        return super()._parse_path(path)
+
+    def __str__(self):
+        if self._protocol:
+            return f"{self._protocol}://{self.path}"
+        else:
+            return self.path
 
-    def relative_to(self: PT, *other: str | PathLike) -> PT:
-        for other_item in other:
-            if not isinstance(other_item, self.__class__) and not isinstance(
-                other_item, str
-            ):
-                raise ValueError(
-                    f"{repr(self)} and {repr(other_item)} are "
-                    "not of compatible classes."
-                )
-            if not isinstance(other_item, str) and (
-                self._url is None
-                or other_item._url is None
-                or other_item._url.scheme != self._url.scheme
-                or other_item._url.netloc != self._url.netloc
-                or other_item._kwargs != self._kwargs
-            ):
-                raise ValueError(
-                    f"{self} and {other_item} do not share the same "
-                    "base URL and storage options."
-                )
-        output: PT = super().relative_to(*other)  # type: ignore
-        output._url = self._url
-        output._kwargs = self._kwargs
-        return output
+    def __fspath__(self):
+        msg = (
+            "in a future version of UPath this will be set to None"
+            " unless the filesystem is local (or caches locally)"
+        )
+        warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
+        return str(self)
 
-    def _scandir(self):
-        # provided in Python3.11 but not required in fsspec glob implementation
-        raise NotImplementedError
+    def __bytes__(self):
+        msg = (
+            "in a future version of UPath this will be set to None"
+            " unless the filesystem is local (or caches locally)"
+        )
+        warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
+        return os.fsencode(self)
 
-    def glob(self: PT, pattern: str) -> Generator[PT, None, None]:
-        path_pattern = self.joinpath(pattern)
-        for name in self._accessor.glob(self, path_pattern):
-            name = self._sub_path(name)
-            name = name.split(self._flavour.sep)
-            yield self._make_child(name)
+    def as_uri(self):
+        return str(self)
 
-    def rglob(self: PT, pattern: str) -> Generator[PT, None, None]:
-        if _FSSPEC_HAS_WORKING_GLOB is None:
-            _check_fsspec_has_working_glob()
+    def is_reserved(self):
+        return False
 
-        if _FSSPEC_HAS_WORKING_GLOB:
-            r_path_pattern = self.joinpath("**", pattern)
-            for name in self._accessor.glob(self, r_path_pattern):
-                name = self._sub_path(name)
-                name = name.split(self._flavour.sep)
-                yield self._make_child(name)
+    def __eq__(self, other):
+        if not isinstance(other, UPath):
+            return NotImplemented
+        return (
+            self.path == other.path
+            and self.storage_options == other.storage_options
+            and (
+                get_filesystem_class(self.protocol)
+                == get_filesystem_class(other.protocol)
+            )
+        )
 
-        else:
-            path_pattern = self.joinpath(pattern)
-            r_path_pattern = self.joinpath("**", pattern)
-            seen = set()
-            for p in (path_pattern, r_path_pattern):
-                for name in self._accessor.glob(self, p):
-                    name = self._sub_path(name)
-                    name = name.split(self._flavour.sep)
-                    pth = self._make_child(name)
-                    if pth.parts not in seen:
-                        yield pth
-                        seen.add(pth.parts)
-
-    def _sub_path(self, name):
-        # only want the path name with iterdir
-        sp = re.escape(self._path)
-        return re.sub(f"^({sp}|{sp[1:]})/?", "", name)
-
-    def absolute(self: PT) -> PT:
-        # fsspec paths are always absolute
-        return self
+    def __hash__(self):
+        return hash((self.path, self.storage_options, self.protocol))
 
-    def resolve(self: PT, strict: bool = False) -> PT:
-        """Return a new path with '.' and '..' parts normalized."""
-        _parts = self._parts
+    def relative_to(self, other, /, *_deprecated, walk_up=False):
+        if isinstance(other, UPath) and self.storage_options != other.storage_options:
+            raise ValueError(
+                "paths have different storage_options:"
+                f" {self.storage_options!r} != {other.storage_options!r}"
+            )
+        return super().relative_to(other, *_deprecated, walk_up=walk_up)
 
-        # Do not attempt to normalize path if no parts are dots
-        if ".." not in _parts and "." not in _parts:
-            return self
+    def is_relative_to(self, other, /, *_deprecated):
+        if isinstance(other, UPath) and self.storage_options != other.storage_options:
+            return False
+        return super().is_relative_to(other, *_deprecated)
 
-        sep = self._flavour.sep
+    # === pathlib.Path ================================================
 
-        resolved: list[str] = []
-        resolvable_parts = _parts[1:]
-        idx_max = len(resolvable_parts) - 1
-        for i, part in enumerate(resolvable_parts):
-            if part == "..":
-                if resolved:
-                    resolved.pop()
-            elif part != ".":
-                if i < idx_max:
-                    part += sep
-                resolved.append(part)
+    def stat(self, *, follow_symlinks=True):
+        return self.fs.stat(self.path)
 
-        path = "".join(resolved)
-        url = self._url
-        if url is not None:
-            url = url._replace(path=path)
-        parts = _parts[:1] + path.split(sep)
-
-        return self._from_parsed_parts(
-            self._drv,
-            self._root,
-            parts,
-            url=url,
-            **self._kwargs,
-        )
+    def lstat(self):
+        # return self.stat(follow_symlinks=False)
+        raise NotImplementedError
 
-    def exists(self) -> bool:
-        """Check whether this path exists or not."""
-        accessor = self._accessor
-        try:
-            return bool(accessor.exists(self))
-        except AttributeError:
-            try:
-                self._accessor.stat(self)
-            except FileNotFoundError:
-                return False
-            return True
+    def exists(self, *, follow_symlinks=True):
+        return self.fs.exists(self.path)
 
-    def is_dir(self) -> bool:
-        try:
-            info = self._accessor.info(self)
-            if info["type"] == "directory":
-                return True
-        except FileNotFoundError:
-            return False
-        return False
+    def is_dir(self):
+        return self.fs.isdir(self.path)
 
-    def is_file(self) -> bool:
-        try:
-            info = self._accessor.info(self)
-            if info["type"] == "file":
-                return True
-        except FileNotFoundError:
-            return False
-        return False
+    def is_file(self):
+        return self.fs.isfile(self.path)
 
-    def is_mount(self) -> bool:
+    def is_mount(self):
         return False
 
-    def is_symlink(self) -> bool:
+    def is_symlink(self):
         try:
-            info = self._accessor.info(self)
+            info = self.fs.info(self.path)
             if "islink" in info:
                 return bool(info["islink"])
         except FileNotFoundError:
             return False
         return False
 
-    def is_socket(self) -> bool:
+    def is_junction(self):
         return False
 
-    def is_fifo(self) -> bool:
+    def is_block_device(self):
         return False
 
-    def is_block_device(self) -> bool:
+    def is_char_device(self):
         return False
 
-    def is_char_device(self) -> bool:
+    def is_fifo(self):
         return False
 
-    def is_absolute(self) -> bool:
-        return True
-
-    def unlink(self, missing_ok: bool = False) -> None:
-        if not self.exists():
-            if not missing_ok:
-                raise FileNotFoundError(str(self))
-            return
-        self._accessor.rm(self, recursive=False)
-
-    def rmdir(self, recursive: bool = True) -> None:
-        if not self.is_dir():
-            raise NotADirectoryError(str(self))
-        if not recursive and next(self.iterdir()):  # type: ignore
-            raise OSError(f"Not recursive and directory not empty: {self}")
-        self._accessor.rm(self, recursive=recursive)
+    def is_socket(self):
+        return False
 
-    def chmod(self, mode, *, follow_symlinks: bool = True) -> None:
+    def samefile(self, other_path):
         raise NotImplementedError
 
-    def rename(self, target, recursive=False, maxdepth=None, **kwargs):
-        """Move file, see `fsspec.AbstractFileSystem.mv`."""
-        if not isinstance(target, UPath):
-            target = self.parent.joinpath(target).resolve()
-        self._accessor.mv(
-            self,
-            target,
-            recursive=recursive,
-            maxdepth=maxdepth,
-            **kwargs,
-        )
-        return target
+    def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
+        return self.fs.open(self.path, mode)  # fixme
 
-    def replace(self, target):
-        raise NotImplementedError
+    def iterdir(self):
+        if getattr(self._flavour, "supports_empty_parts", False) and self.parts[
+            -1:
+        ] == ("",):
+            base = self.with_segments(self.anchor, *self._tail[:-1])
+        else:
+            base = self
+        for name in self.fs.listdir(self.path):
+            # fsspec returns dictionaries
+            if isinstance(name, dict):
+                name = name.get("name")
+            if name in {".", ".."}:
+                # Yielding a path object for these makes little sense
+                continue
+            # only want the path name with iterdir
+            _, _, name = str_remove_suffix(name, "/").rpartition(self._flavour.sep)
+            yield base._make_child_relpath(name)
 
-    def symlink_to(self, target, target_is_directory=False):
-        raise NotImplementedError
+    def _scandir(self):
+        raise NotImplementedError  # todo
 
-    def hardlink_to(self, target):
-        raise NotImplementedError
+    def _make_child_relpath(self, name):
+        path = super()._make_child_relpath(name)
+        del path._str  # fix _str = str(self) assignment
+        return path
 
-    def link_to(self, target):
-        raise NotImplementedError
+    def glob(self, pattern: str, *, case_sensitive=None):
+        path_pattern = self.joinpath(pattern).path
+        sep = self._flavour.sep
+        for name in self.fs.glob(path_pattern):
+            name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
+            yield self.joinpath(name)
+
+    def rglob(self, pattern: str, *, case_sensitive=None):
+        r_path_pattern = self.joinpath("**", pattern).path
+        sep = self._flavour.sep
+        for name in self.fs.glob(r_path_pattern):
+            name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
+            yield self.joinpath(name)
 
     @classmethod
     def cwd(cls):
@@ -630,50 +463,54 @@ def home(cls):
         else:
             raise NotImplementedError
 
-    def expanduser(self):
-        raise NotImplementedError
+    def absolute(self) -> Self:
+        return self
 
-    def group(self):
-        raise NotImplementedError
+    def resolve(self, strict: bool = False) -> Self:
+        _parts = self.parts
 
-    def lchmod(self, mode):
-        raise NotImplementedError
+        # Do not attempt to normalize path if no parts are dots
+        if ".." not in _parts and "." not in _parts:
+            return self
 
-    def lstat(self):
-        raise NotImplementedError
+        resolved: list[str] = []
+        resolvable_parts = _parts[1:]
+        last_idx = len(resolvable_parts) - 1
+        for idx, part in enumerate(resolvable_parts):
+            if part == "..":
+                if resolved:
+                    resolved.pop()
+                if (
+                    getattr(self._flavour, "supports_empty_parts", False)
+                    and idx == last_idx
+                ):
+                    resolved.append("")
+            elif part != ".":
+                resolved.append(part)
+
+        return self.with_segments(*_parts[:1], *resolved)
 
     def owner(self):
         raise NotImplementedError
 
+    def group(self):
+        raise NotImplementedError
+
     def readlink(self):
         raise NotImplementedError
 
-    def touch(self, *args: int, truncate: bool = True, **kwargs) -> None:
-        # Keep the calling signature compatible with Path
-        # (without changing current fsspec behavior for defaults)
-        if len(args) > 2:
-            raise TypeError("too many arguments")
-        else:
-            for key, val in zip(["mode", "exists_ok"], args):
-                if key in kwargs:
-                    raise TypeError(f"provided {key!r} as arg and kwarg")
-                kwargs[key] = val
-        self._accessor.touch(self, truncate=truncate, **kwargs)
-
-    def mkdir(
-        self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False
-    ) -> None:
-        """
-        Create a new directory at this given path.
-        """
+    def touch(self, mode=0o666, exist_ok=True):
+        self.fs.touch(self.path, truncate=not exist_ok)
+
+    def mkdir(self, mode=0o777, parents=False, exist_ok=False):
         if parents:
             if not exist_ok and self.exists():
                 raise FileExistsError(str(self))
-            self._accessor.makedirs(self, exist_ok=exist_ok)
+            self.fs.makedirs(self.path, exist_ok=exist_ok)
         else:
             try:
-                self._accessor.mkdir(
-                    self,
+                self.fs.mkdir(
+                    self.path,
                     create_parents=False,
                     mode=mode,
                 )
@@ -681,231 +518,45 @@ def mkdir(
                 if not exist_ok or not self.is_dir():
                     raise FileExistsError(str(self))
 
-    @classmethod
-    def _from_parts(
-        cls: type[PT],
-        args: list[str | PathLike],
-        url: SplitResult | None = None,
-        **kwargs: Any,
-    ) -> PT:
-        obj = object.__new__(cls)
-        drv, root, parts = obj._parse_args(args)
-        obj._drv = drv
-        if sys.version_info < (3, 9):
-            obj._closed = False
-        obj._kwargs = kwargs.copy()
-
-        if not root:
-            if not parts:
-                root = "/"
-                parts = ["/"]
-            elif parts[0] == "/":
-                root = parts[1:]
-        obj._root = root
-        obj._parts = parts
-
-        # Update to (full) URL
-        if url:
-            url = url._replace(path=root + cls._flavour.join(parts[1:]))
-        obj._url = url
-
-        return obj
-
-    @classmethod
-    def _from_parsed_parts(
-        cls: type[PT],
-        drv: str,
-        root: str,
-        parts: list[str],
-        url: SplitResult | None = None,
-        **kwargs: Any,
-    ) -> PT:
-        obj = object.__new__(cls)
-        obj._drv = drv
-        obj._parts = parts
-        if sys.version_info < (3, 9):
-            obj._closed = False
-        obj._kwargs = kwargs.copy()
-
-        if not root:
-            if not parts:
-                root = "/"
-            elif parts[0] == "/":
-                root = parts.pop(0)
-        if len(obj._parts) == 0 or obj._parts[0] != root:
-            obj._parts.insert(0, root)
-        obj._root = root
-
-        if url:
-            url = url._replace(path=root + cls._flavour.join(parts[1:]))
-        obj._url = url
-        return obj
-
-    def __eq__(self, other):
-        if not isinstance(other, self.__class__):
-            return NotImplemented
-        p0, p1 = self.parts, other.parts
-        if len(p0) > len(p1):
-            if p0 and p0[-1] == "":
-                p0 = p0[:-1]
-        elif len(p1) > len(p0):
-            if p1 and p1[-1] == "":
-                p1 = p1[:-1]
-        return (
-            p0 == p1
-            and self.protocol == other.protocol
-            and self.storage_options == other.storage_options
-        )
-
-    def __str__(self) -> str:
-        """Return the string representation of the path, suitable for
-        passing to system calls."""
-        try:
-            return self._str
-        except AttributeError:
-            self._str = self._format_parsed_parts(
-                self._drv,
-                self._root,
-                self._parts,
-                url=self._url,
-                **self._kwargs,
-            )
-            return self._str
-
-    def __truediv__(self: PT, key: str | PathLike) -> PT:
-        # Add `/` root if not present
-        if len(self._parts) == 0:
-            key = f"{self._root}{key}"
+    def chmod(self, mode, *, follow_symlinks=True):
+        raise NotImplementedError
 
-        # Adapted from `PurePath._make_child`
-        drv, root, parts = self._parse_args((key,))
-        drv, root, parts = self._flavour.join_parsed_parts(
-            self._drv, self._root, self._parts, drv, root, parts
-        )
+    def unlink(self, missing_ok=False):
+        if not self.exists():
+            if not missing_ok:
+                raise FileNotFoundError(str(self))
+            return
+        self.fs.rm(self.path, recursive=False)
 
-        kwargs = self._kwargs.copy()
+    def rmdir(self, recursive: bool = True):  # fixme: non-standard
+        if not self.is_dir():
+            raise NotADirectoryError(str(self))
+        if not recursive and next(self.iterdir()):
+            raise OSError(f"Not recursive and directory not empty: {self}")
+        self.fs.rm(self.path, recursive=recursive)
 
-        # Create a new object
-        out = self.__class__(
-            self._format_parsed_parts(drv, root, parts, url=self._url),
+    def rename(
+        self, target, *, recursive=False, maxdepth=None, **kwargs
+    ):  # fixme: non-standard
+        if not isinstance(target, UPath):
+            target = self.parent.joinpath(target).resolve()
+        self.fs.mv(
+            self.path,
+            target.path,
+            recursive=recursive,
+            maxdepth=maxdepth,
             **kwargs,
         )
-        return out
-
-    def __setstate__(self, state: dict) -> None:
-        self._kwargs = state["_kwargs"].copy()
-
-    def __reduce__(self):
-        cls = type(self)
-        return (
-            cls,
-            (
-                cls._format_parsed_parts(
-                    self._drv, self._root, self._parts, url=self._url
-                ),
-            ),
-            {"_kwargs": self._kwargs.copy()},
-        )
-
-    def with_suffix(self: PT, suffix: str) -> PT:
-        """Return a new path with the file suffix changed.  If the path
-        has no suffix, add given suffix.  If the given suffix is an empty
-        string, remove the suffix from the path.
-        """
-        f = self._flavour
-        if f.sep in suffix or f.altsep and f.altsep in suffix:
-            raise ValueError(f"Invalid suffix {suffix!r}")
-        if suffix and not suffix.startswith(".") or suffix == ".":
-            raise ValueError("Invalid suffix %r" % (suffix))
-        name = self.name
-        if not name:
-            raise ValueError(f"{self!r} has an empty name")
-        old_suffix = self.suffix
-        if not old_suffix:
-            name = name + suffix
-        else:
-            name = name[: -len(old_suffix)] + suffix
-        return self._from_parsed_parts(
-            self._drv,
-            self._root,
-            self._parts[:-1] + [name],
-            url=self._url,
-            **self._kwargs,
-        )
-
-    def with_name(self: PT, name: str) -> PT:
-        """Return a new path with the file name changed."""
-        if not self.name:
-            raise ValueError(f"{self!r} has an empty name")
-        drv, root, parts = self._flavour.parse_parts((name,))
-        if (
-            not name
-            or name[-1] in [self._flavour.sep, self._flavour.altsep]
-            or drv
-            or root
-            or len(parts) != 1
-        ):
-            raise ValueError("Invalid name %r" % (name))
-        return self._from_parsed_parts(
-            self._drv,
-            self._root,
-            self._parts[:-1] + [name],
-            url=self._url,
-            **self._kwargs,
-        )
-
-    @property
-    def parents(self) -> _UPathParents:
-        """A sequence of this upath's logical parents."""
-        return _UPathParents(self)
-
-    def as_uri(self) -> str:
-        return str(self)
-
+        return target
 
-class _UPathParents(Sequence[UPath]):
-    """This object provides sequence-like access to the logical ancestors
-    of a path.  Don't try to construct it yourself."""
+    def replace(self, target):
+        raise NotImplementedError  # todo
 
-    __slots__ = (
-        "_pathcls",
-        "_drv",
-        "_root",
-        "_parts",
-        "_url",
-        "_kwargs",
-    )
+    def symlink_to(self, target, target_is_directory=False):
+        raise NotImplementedError
 
-    def __init__(self, path):
-        # We don't store the instance to avoid reference cycles
-        self._pathcls = type(path)
-        self._drv = path._drv
-        self._root = path._root
-        self._parts = path._parts
-        self._url = path._url
-        self._kwargs = path._kwargs
-
-    def __len__(self):
-        if self._drv or self._root:
-            return len(self._parts) - 1
-        else:
-            return len(self._parts)
-
-    def __getitem__(self, idx):
-        if isinstance(idx, slice):
-            return tuple(self[i] for i in range(*idx.indices(len(self))))
-
-        if idx >= len(self) or idx < -len(self):
-            raise IndexError(idx)
-        if idx < 0:
-            idx += len(self)
-        return self._pathcls._from_parsed_parts(
-            self._drv,
-            self._root,
-            self._parts[: -idx - 1],
-            url=self._url,
-            **self._kwargs,
-        )
+    def hardlink_to(self, target):
+        raise NotImplementedError
 
-    def __repr__(self):
-        return f"<{self._pathcls.__name__}.parents>"
+    def expanduser(self):
+        raise NotImplementedError
diff --git a/upath/core312plus.py b/upath/core312plus.py
deleted file mode 100644
index e3be4ad1..00000000
--- a/upath/core312plus.py
+++ /dev/null
@@ -1,562 +0,0 @@
-from __future__ import annotations
-
-import os
-import sys
-import warnings
-from copy import copy
-from pathlib import Path
-from types import MappingProxyType
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Mapping
-from typing import cast
-from urllib.parse import SplitResult
-from urllib.parse import urlsplit
-
-if sys.version_info >= (3, 11):
-    from typing import Self
-else:
-    Self = Any
-
-from fsspec import AbstractFileSystem
-from fsspec import get_filesystem_class
-
-from upath._compat import PathlibPathShim
-from upath._compat import str_remove_prefix
-from upath._compat import str_remove_suffix
-from upath._flavour import FSSpecFlavour
-from upath._protocol import get_upath_protocol
-from upath.registry import get_upath_class
-
-
-class _FSSpecAccessor:
-    """this is a compatibility shim and will be removed"""
-
-    def __init__(self, parsed_url: SplitResult | None, **kwargs: Any) -> None:
-        pass
-
-
-def _make_instance(cls, args, kwargs):
-    """helper for pickling UPath instances"""
-    return cls(*args, **kwargs)
-
-
-class UPath(PathlibPathShim, Path):
-    __slots__ = (
-        "_protocol",
-        "_storage_options",
-        "_fs_cached",
-        *PathlibPathShim.__missing_py312_slots__,
-    )
-    if TYPE_CHECKING:
-        _protocol: str
-        _storage_options: dict[str, Any]
-        _fs_cached: AbstractFileSystem
-
-    _flavour = FSSpecFlavour()
-
-    def __new__(
-        cls, *args, protocol: str | None = None, **storage_options: Any
-    ) -> UPath:
-        # fill empty arguments
-        if not args:
-            args = (".",)
-
-        # create a copy if UPath class
-        part0, *parts = args
-        if not parts and not storage_options and isinstance(part0, cls):
-            return copy(part0)
-
-        # deprecate 'scheme'
-        if "scheme" in storage_options:
-            warnings.warn(
-                "use 'protocol' kwarg instead of 'scheme'",
-                DeprecationWarning,
-                stacklevel=2,
-            )
-            protocol = storage_options.pop("scheme")
-
-        # determine which UPath subclass to dispatch to
-        pth_protocol = get_upath_protocol(
-            part0, protocol=protocol, storage_options=storage_options
-        )
-        upath_cls = get_upath_class(protocol=pth_protocol)
-        if upath_cls is None:
-            raise ValueError(f"Unsupported filesystem: {pth_protocol!r}")
-
-        # create a new instance
-        if cls is UPath:
-            # we called UPath() directly, and want an instance based on the
-            # provided or detected protocol (i.e. upath_cls)
-            obj: UPath = cast("UPath", object.__new__(upath_cls))
-            obj._protocol = pth_protocol
-
-        elif issubclass(cls, upath_cls):
-            # we called a sub- or sub-sub-class of UPath, i.e. S3Path() and the
-            # corresponding upath_cls based on protocol is equal-to or a
-            # parent-of the cls.
-            obj = cast("UPath", object.__new__(cls))  # type: ignore[unreachable]
-            obj._protocol = pth_protocol
-
-        elif issubclass(cls, UPath):
-            # we called a subclass of UPath directly, i.e. S3Path() but the
-            # detected protocol would return a non-related UPath subclass, i.e.
-            # S3Path("file:///abc"). This behavior is going to raise an error
-            # in future versions
-            msg_protocol = repr(pth_protocol)
-            if not pth_protocol:
-                msg_protocol += " (empty string)"
-            msg = (
-                f"{cls.__name__!s}(...) detected protocol {msg_protocol!s} and"
-                f" returns a {upath_cls.__name__} instance that isn't a direct"
-                f" subclass of {cls.__name__}. This will raise an exception in"
-                " future universal_pathlib versions. To prevent the issue, use"
-                " UPath(...) to create instances of unrelated protocols or you"
-                f" can instead derive your subclass {cls.__name__!s}(...) from"
-                f" {upath_cls.__name__} or alternatively override behavior via"
-                f" registering the {cls.__name__} implementation with protocol"
-                f" {msg_protocol!s} replacing the default implementation."
-            )
-            warnings.warn(msg, DeprecationWarning, stacklevel=2)
-
-            obj = cast("UPath", object.__new__(upath_cls))
-            obj._protocol = pth_protocol
-
-            upath_cls.__init__(
-                obj, *args, protocol=pth_protocol, **storage_options
-            )  # type: ignore
-
-        else:
-            raise RuntimeError("UPath.__new__ expected cls to be subclass of UPath")
-
-        return obj
-
-    def __init__(
-        self, *args, protocol: str | None = None, **storage_options: Any
-    ) -> None:
-        # allow subclasses to customize __init__ arg parsing
-        base_options = getattr(self, "_storage_options", {})
-        args, protocol, storage_options = type(self)._transform_init_args(
-            args, protocol, {**base_options, **storage_options}
-        )
-        if self._protocol != protocol and protocol:
-            self._protocol = protocol
-
-        # retrieve storage_options
-        if args:
-            args0 = args[0]
-            if isinstance(args0, UPath):
-                self._storage_options = {**args0.storage_options, **storage_options}
-            else:
-                fs_cls: type[AbstractFileSystem] = get_filesystem_class(
-                    protocol or self._protocol
-                )
-                pth_storage_options = fs_cls._get_kwargs_from_urls(str(args0))
-                self._storage_options = {**pth_storage_options, **storage_options}
-        else:
-            self._storage_options = storage_options.copy()
-
-        # check that UPath subclasses in args are compatible
-        # --> ensures items in _raw_paths are compatible
-        for arg in args:
-            if not isinstance(arg, UPath):
-                continue
-            # protocols: only identical (or empty "") protocols can combine
-            if arg.protocol and arg.protocol != self._protocol:
-                raise TypeError("can't combine different UPath protocols as parts")
-            # storage_options: args may not define other storage_options
-            if any(
-                self._storage_options.get(key) != value
-                for key, value in arg.storage_options.items()
-            ):
-                # raise ValueError(
-                #     "can't combine different UPath storage_options as parts"
-                # ) todo: revisit and define behaviour
-                pass
-
-        # fill ._raw_paths
-        super().__init__(*args)
-
-    # === upath.UPath only ============================================
-
-    @classmethod
-    def _transform_init_args(
-        cls,
-        args: tuple[str | os.PathLike, ...],
-        protocol: str,
-        storage_options: dict[str, Any],
-    ) -> tuple[tuple[str | os.PathLike, ...], str, dict[str, Any]]:
-        """allow customization of init args in subclasses"""
-        return args, protocol, storage_options
-
-    @property
-    def protocol(self) -> str:
-        return self._protocol
-
-    @property
-    def storage_options(self) -> Mapping[str, Any]:
-        return MappingProxyType(self._storage_options)
-
-    def __init_subclass__(cls, **kwargs):
-        """provide a clean migration path for custom user subclasses"""
-
-        accessor_cls = getattr(cls, "_default_accessor", None)
-
-        # guess which parts the user subclass is customizing
-        has_custom_legacy_accessor = (
-            accessor_cls is not None
-            and issubclass(accessor_cls, _FSSpecAccessor)
-            and accessor_cls is not _FSSpecAccessor
-        )
-        has_customized_fs_instantiation = (
-            accessor_cls.__init__ is not _FSSpecAccessor.__init__
-            or hasattr(accessor_cls, "_fs")
-        )
-
-        if has_custom_legacy_accessor and has_customized_fs_instantiation:
-            warnings.warn(
-                "Detected a customized `__init__` method or `_fs` attribute"
-                " in the provided `_FSSpecAccessor` subclass. It is recommended to"
-                " instead override the `_fs_factory` classmethod to customize"
-                " filesystem instantiation.",
-                DeprecationWarning,
-                stacklevel=2,
-            )
-
-            def _fs_factory(
-                cls_, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
-            ) -> AbstractFileSystem:
-                url = urlsplit(urlpath)
-                if protocol:
-                    url = url._replace(scheme=protocol)
-                inst = cls_._default_accessor(url, **storage_options)
-                return inst._fs
-
-            cls._fs_factory = classmethod(_fs_factory)
-
-    @classmethod
-    def _fs_factory(
-        cls, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
-    ) -> AbstractFileSystem:
-        """Instantiate the filesystem_spec filesystem class"""
-        fs_cls = get_filesystem_class(protocol)
-        so_dct = fs_cls._get_kwargs_from_urls(urlpath)
-        so_dct.update(storage_options)
-        return fs_cls(**storage_options)
-
-    @property
-    def fs(self) -> AbstractFileSystem:
-        try:
-            return self._fs_cached
-        except AttributeError:
-            fs = self._fs_cached = self._fs_factory(
-                str(self), self.protocol, self.storage_options
-            )
-            return fs
-
-    @property
-    def path(self) -> str:
-        return super().__str__()
-
-    @property
-    def _kwargs(self):
-        warnings.warn(
-            "use UPath.storage_options instead of UPath._kwargs",
-            DeprecationWarning,
-            stacklevel=2,
-        )
-        return self.storage_options
-
-    @property
-    def _url(self):  # todo: deprecate
-        return urlsplit(self.as_posix())
-
-    # === pathlib.PurePath ============================================
-
-    def __reduce__(self):
-        args = tuple(self._raw_paths)
-        kwargs = {
-            "protocol": self._protocol,
-            **self._storage_options,
-        }
-        return _make_instance, (type(self), args, kwargs)
-
-    def with_segments(self, *pathsegments):
-        return type(self)(
-            *pathsegments,
-            protocol=self._protocol,
-            **self._storage_options,
-        )
-
-    @classmethod
-    def _parse_path(cls, path):
-        if getattr(cls._flavour, "supports_empty_parts", False):
-            drv, root, rel = cls._flavour.splitroot(path)
-            if not root:
-                parsed = []
-            else:
-                parsed = list(map(sys.intern, rel.split(cls._flavour.sep)))
-                if parsed[-1] == ".":
-                    parsed[-1] = ""
-                parsed = [x for x in parsed if x != "."]
-            return drv, root, parsed
-        return super()._parse_path(path)
-
-    def __str__(self):
-        if self._protocol:
-            return f"{self._protocol}://{self.path}"
-        else:
-            return self.path
-
-    def __fspath__(self):
-        msg = (
-            "in a future version of UPath this will be set to None"
-            " unless the filesystem is local (or caches locally)"
-        )
-        warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
-        return str(self)
-
-    def __bytes__(self):
-        msg = (
-            "in a future version of UPath this will be set to None"
-            " unless the filesystem is local (or caches locally)"
-        )
-        warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
-        return os.fsencode(self)
-
-    def as_uri(self):
-        return str(self)
-
-    def is_reserved(self):
-        return False
-
-    def __eq__(self, other):
-        if not isinstance(other, UPath):
-            return NotImplemented
-        return (
-            self.path == other.path
-            and self.storage_options == other.storage_options
-            and (
-                get_filesystem_class(self.protocol)
-                == get_filesystem_class(other.protocol)
-            )
-        )
-
-    def __hash__(self):
-        return hash((self.path, self.storage_options, self.protocol))
-
-    def relative_to(self, other, /, *_deprecated, walk_up=False):
-        if isinstance(other, UPath) and self.storage_options != other.storage_options:
-            raise ValueError(
-                "paths have different storage_options:"
-                f" {self.storage_options!r} != {other.storage_options!r}"
-            )
-        return super().relative_to(other, *_deprecated, walk_up=walk_up)
-
-    def is_relative_to(self, other, /, *_deprecated):
-        if isinstance(other, UPath) and self.storage_options != other.storage_options:
-            return False
-        return super().is_relative_to(other, *_deprecated)
-
-    # === pathlib.Path ================================================
-
-    def stat(self, *, follow_symlinks=True):
-        return self.fs.stat(self.path)
-
-    def lstat(self):
-        # return self.stat(follow_symlinks=False)
-        raise NotImplementedError
-
-    def exists(self, *, follow_symlinks=True):
-        return self.fs.exists(self.path)
-
-    def is_dir(self):
-        return self.fs.isdir(self.path)
-
-    def is_file(self):
-        return self.fs.isfile(self.path)
-
-    def is_mount(self):
-        return False
-
-    def is_symlink(self):
-        try:
-            info = self.fs.info(self.path)
-            if "islink" in info:
-                return bool(info["islink"])
-        except FileNotFoundError:
-            return False
-        return False
-
-    def is_junction(self):
-        return False
-
-    def is_block_device(self):
-        return False
-
-    def is_char_device(self):
-        return False
-
-    def is_fifo(self):
-        return False
-
-    def is_socket(self):
-        return False
-
-    def samefile(self, other_path):
-        raise NotImplementedError
-
-    def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
-        return self.fs.open(self.path, mode)  # fixme
-
-    def iterdir(self):
-        if getattr(self._flavour, "supports_empty_parts", False) and self.parts[
-            -1:
-        ] == ("",):
-            base = self.with_segments(self.anchor, *self._tail[:-1])
-        else:
-            base = self
-        for name in self.fs.listdir(self.path):
-            # fsspec returns dictionaries
-            if isinstance(name, dict):
-                name = name.get("name")
-            if name in {".", ".."}:
-                # Yielding a path object for these makes little sense
-                continue
-            # only want the path name with iterdir
-            _, _, name = str_remove_suffix(name, "/").rpartition(self._flavour.sep)
-            yield base._make_child_relpath(name)
-
-    def _scandir(self):
-        raise NotImplementedError  # todo
-
-    def _make_child_relpath(self, name):
-        path = super()._make_child_relpath(name)
-        del path._str  # fix _str = str(self) assignment
-        return path
-
-    def glob(self, pattern: str, *, case_sensitive=None):
-        path_pattern = self.joinpath(pattern).path
-        sep = self._flavour.sep
-        for name in self.fs.glob(path_pattern):
-            name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
-            yield self.joinpath(name)
-
-    def rglob(self, pattern: str, *, case_sensitive=None):
-        r_path_pattern = self.joinpath("**", pattern).path
-        sep = self._flavour.sep
-        for name in self.fs.glob(r_path_pattern):
-            name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
-            yield self.joinpath(name)
-
-    @classmethod
-    def cwd(cls):
-        if cls is UPath:
-            return get_upath_class("").cwd()
-        else:
-            raise NotImplementedError
-
-    @classmethod
-    def home(cls):
-        if cls is UPath:
-            return get_upath_class("").home()
-        else:
-            raise NotImplementedError
-
-    def absolute(self) -> Self:
-        return self
-
-    def resolve(self, strict: bool = False) -> Self:
-        _parts = self.parts
-
-        # Do not attempt to normalize path if no parts are dots
-        if ".." not in _parts and "." not in _parts:
-            return self
-
-        resolved: list[str] = []
-        resolvable_parts = _parts[1:]
-        last_idx = len(resolvable_parts) - 1
-        for idx, part in enumerate(resolvable_parts):
-            if part == "..":
-                if resolved:
-                    resolved.pop()
-                if (
-                    getattr(self._flavour, "supports_empty_parts", False)
-                    and idx == last_idx
-                ):
-                    resolved.append("")
-            elif part != ".":
-                resolved.append(part)
-
-        return self.with_segments(*_parts[:1], *resolved)
-
-    def owner(self):
-        raise NotImplementedError
-
-    def group(self):
-        raise NotImplementedError
-
-    def readlink(self):
-        raise NotImplementedError
-
-    def touch(self, mode=0o666, exist_ok=True):
-        self.fs.touch(self.path, truncate=not exist_ok)
-
-    def mkdir(self, mode=0o777, parents=False, exist_ok=False):
-        if parents:
-            if not exist_ok and self.exists():
-                raise FileExistsError(str(self))
-            self.fs.makedirs(self.path, exist_ok=exist_ok)
-        else:
-            try:
-                self.fs.mkdir(
-                    self.path,
-                    create_parents=False,
-                    mode=mode,
-                )
-            except FileExistsError:
-                if not exist_ok or not self.is_dir():
-                    raise FileExistsError(str(self))
-
-    def chmod(self, mode, *, follow_symlinks=True):
-        raise NotImplementedError
-
-    def unlink(self, missing_ok=False):
-        if not self.exists():
-            if not missing_ok:
-                raise FileNotFoundError(str(self))
-            return
-        self.fs.rm(self.path, recursive=False)
-
-    def rmdir(self, recursive: bool = True):  # fixme: non-standard
-        if not self.is_dir():
-            raise NotADirectoryError(str(self))
-        if not recursive and next(self.iterdir()):
-            raise OSError(f"Not recursive and directory not empty: {self}")
-        self.fs.rm(self.path, recursive=recursive)
-
-    def rename(
-        self, target, *, recursive=False, maxdepth=None, **kwargs
-    ):  # fixme: non-standard
-        if not isinstance(target, UPath):
-            target = self.parent.joinpath(target).resolve()
-        self.fs.mv(
-            self.path,
-            target.path,
-            recursive=recursive,
-            maxdepth=maxdepth,
-            **kwargs,
-        )
-        return target
-
-    def replace(self, target):
-        raise NotImplementedError  # todo
-
-    def symlink_to(self, target, target_is_directory=False):
-        raise NotImplementedError
-
-    def hardlink_to(self, target):
-        raise NotImplementedError
-
-    def expanduser(self):
-        raise NotImplementedError

From 93b2c09883a506bccb9ac8dad6fceda83b345543 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 03:01:39 +0100
Subject: [PATCH 18/50] tests: skip/xfail obsolete tests

---
 upath/tests/cases.py     | 4 ++--
 upath/tests/test_core.py | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/upath/tests/cases.py b/upath/tests/cases.py
index bcd43824..64884f01 100644
--- a/upath/tests/cases.py
+++ b/upath/tests/cases.py
@@ -191,7 +191,7 @@ def test_mkdir_parents_true_exists_ok_false(self):
         with pytest.raises(FileExistsError):
             new_dir.mkdir(parents=True, exist_ok=False)
 
-    @pytest.mark.xfail(sys.version_info >= (3, 12), reason="only valid on python<=3.11")
+    @pytest.mark.skip(reason="_accessor is unsupported in universal_pathlib>0.1.4")
     def test_makedirs_exist_ok_true(self):
         new_dir = self.path.joinpath("parent", "child", "dir_may_not_exist")
         new_dir._accessor.makedirs(new_dir, exist_ok=True)
@@ -199,7 +199,7 @@ def test_makedirs_exist_ok_true(self):
             new_dir.joinpath(".file").touch()
         new_dir._accessor.makedirs(new_dir, exist_ok=True)
 
-    @pytest.mark.xfail(sys.version_info >= (3, 12), reason="only valid on python<=3.11")
+    @pytest.mark.skip(reason="_accessor is unsupported in universal_pathlib>0.1.4")
     def test_makedirs_exist_ok_false(self):
         new_dir = self.path.joinpath("parent", "child", "dir_may_exist")
         new_dir._accessor.makedirs(new_dir, exist_ok=False)
diff --git a/upath/tests/test_core.py b/upath/tests/test_core.py
index 10b625e6..df1816db 100644
--- a/upath/tests/test_core.py
+++ b/upath/tests/test_core.py
@@ -117,7 +117,7 @@ def test_instance_check_local_uri(local_testdir):
     assert isinstance(upath, UPath)
 
 
-@pytest.mark.xfail(sys.version_info >= (3, 12), reason="requires python<3.12")
+@pytest.mark.xfail(reason="unsupported on universal_pathlib>0.1.4")
 def test_new_method(local_testdir):
     path = UPath.__new__(pathlib.Path, local_testdir)
     assert str(path) == str(pathlib.Path(local_testdir))

From 652f80039a684c23c622f77037f0de0b3836a298 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 03:02:30 +0100
Subject: [PATCH 19/50] tests: memory fs normalization now passes on py38+

---
 upath/tests/implementations/test_memory.py | 11 +----------
 1 file changed, 1 insertion(+), 10 deletions(-)

diff --git a/upath/tests/implementations/test_memory.py b/upath/tests/implementations/test_memory.py
index 8e84dc9d..7a0b9aea 100644
--- a/upath/tests/implementations/test_memory.py
+++ b/upath/tests/implementations/test_memory.py
@@ -1,5 +1,3 @@
-import sys
-
 import pytest
 
 from upath import UPath
@@ -28,14 +26,7 @@ def test_is_MemoryPath(self):
         ("memory:/a", "memory://a"),
         ("memory:/a/b", "memory://a/b"),
         ("memory://", "memory://"),
-        pytest.param(
-            "memory://a",
-            "memory://a",
-            marks=pytest.mark.xfail(
-                sys.version_info < (3, 12),
-                reason="currently broken due to urllib parsing",
-            ),
-        ),
+        ("memory://a", "memory://a"),
         ("memory://a/b", "memory://a/b"),
         ("memory:///", "memory://"),
         ("memory:///a", "memory://a"),

From a2c20980554c3308a1770df719289da3e2f6bbf2 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 03:15:07 +0100
Subject: [PATCH 20/50] upath: typo fixes in implementations.local

---
 upath/implementations/local.py          | 11 +++++------
 upath/tests/pathlib/test_pathlib_312.py |  2 +-
 2 files changed, 6 insertions(+), 7 deletions(-)

diff --git a/upath/implementations/local.py b/upath/implementations/local.py
index 406ee172..eca21962 100644
--- a/upath/implementations/local.py
+++ b/upath/implementations/local.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import pathlib
 import sys
 from inspect import ismemberdescriptor
 from pathlib import Path
@@ -99,8 +98,8 @@ def __init__(
             self._storage_options = {}
 
         @classmethod
-        def _from_parts(cls, args, **kwargs):
-            obj = super(Path, cls)._from_parts(cls, args, **kwargs)
+        def _from_parts(cls, *args, **kwargs):
+            obj = super(Path, cls)._from_parts(cls, *args, **kwargs)
             obj._protocol = ""
             obj._storage_options = {}
             return obj
@@ -128,7 +127,7 @@ class WindowsUPath(WindowsPath, LocalPath):
         def __new__(
             cls, *args, protocol: str | None = None, **storage_options: Any
         ) -> UPath:
-            obj = super(pathlib.Path).__new__(cls, *args)
+            obj = super().__new__(cls, *args)
             obj._protocol = ""
             return obj
 
@@ -140,8 +139,8 @@ def __init__(
             self._storage_options = {}
 
         @classmethod
-        def _from_parts(cls, args, **kwargs):
-            obj = super(Path, cls)._from_parts(cls, args, **kwargs)
+        def _from_parts(cls, *args, **kwargs):
+            obj = super(Path, cls)._from_parts(cls, *args, **kwargs)
             obj._protocol = ""
             obj._storage_options = {}
             return obj
diff --git a/upath/tests/pathlib/test_pathlib_312.py b/upath/tests/pathlib/test_pathlib_312.py
index 71cab6b8..47e2865a 100644
--- a/upath/tests/pathlib/test_pathlib_312.py
+++ b/upath/tests/pathlib/test_pathlib_312.py
@@ -23,7 +23,7 @@
     grp = pwd = None
 
 import upath
-from upath.core312plus import UPath
+from upath.core import UPath
 from upath.implementations.local import PosixUPath, WindowsUPath
 
 import pytest

From ed3cfb4b9c03eda02673298a6b08dcd1009ee62e Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 11:11:33 +0100
Subject: [PATCH 21/50] upath.implementations.local: fix issues with py312 base

---
 upath/implementations/local.py | 37 +++++++++++++++++++++++-----------
 1 file changed, 25 insertions(+), 12 deletions(-)

diff --git a/upath/implementations/local.py b/upath/implementations/local.py
index eca21962..4164a5ac 100644
--- a/upath/implementations/local.py
+++ b/upath/implementations/local.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import os
 import sys
 from inspect import ismemberdescriptor
 from pathlib import Path
@@ -75,6 +76,14 @@ def _set_class_attributes(
             type_dict[attr] = func_or_value
 
 
+def _upath_init(inst: PosixUPath | WindowsUPath) -> None:
+    """helper to initialize the PosixPath/WindowsPath instance with UPath attrs"""
+    inst._protocol = ""
+    inst._storage_options = {}
+    if sys.version_info < (3, 10):
+        inst._init()
+
+
 class PosixUPath(PosixPath, LocalPath):
     __slots__ = ()
 
@@ -86,6 +95,10 @@ class PosixUPath(PosixPath, LocalPath):
         def __new__(
             cls, *args, protocol: str | None = None, **storage_options: Any
         ) -> UPath:
+            if os.name == "nt":
+                raise NotImplementedError(
+                    f"cannot instantiate {cls.__name__} on your system"
+                )
             obj = super().__new__(cls, *args)
             obj._protocol = ""
             return obj
@@ -95,20 +108,18 @@ def __init__(
         ) -> None:
             super(Path, self).__init__()
             self._drv, self._root, self._parts = type(self)._parse_args(args)
-            self._storage_options = {}
+            _upath_init(self)
 
         @classmethod
         def _from_parts(cls, *args, **kwargs):
-            obj = super(Path, cls)._from_parts(cls, *args, **kwargs)
-            obj._protocol = ""
-            obj._storage_options = {}
+            obj = super(Path, cls)._from_parts(*args, **kwargs)
+            _upath_init(obj)
             return obj
 
         @classmethod
         def _from_parsed_parts(cls, drv, root, parts):
             obj = super(Path, cls)._from_parsed_parts(drv, root, parts)
-            obj._protocol = ""
-            obj._storage_options = {}
+            _upath_init(obj)
             return obj
 
         @property
@@ -127,6 +138,10 @@ class WindowsUPath(WindowsPath, LocalPath):
         def __new__(
             cls, *args, protocol: str | None = None, **storage_options: Any
         ) -> UPath:
+            if os.name != "nt":
+                raise NotImplementedError(
+                    f"cannot instantiate {cls.__name__} on your system"
+                )
             obj = super().__new__(cls, *args)
             obj._protocol = ""
             return obj
@@ -136,20 +151,18 @@ def __init__(
         ) -> None:
             super(Path, self).__init__(*args)
             self._drv, self._root, self._parts = self._parse_args(args)
-            self._storage_options = {}
+            _upath_init(self)
 
         @classmethod
         def _from_parts(cls, *args, **kwargs):
-            obj = super(Path, cls)._from_parts(cls, *args, **kwargs)
-            obj._protocol = ""
-            obj._storage_options = {}
+            obj = super(Path, cls)._from_parts(*args, **kwargs)
+            _upath_init(obj)
             return obj
 
         @classmethod
         def _from_parsed_parts(cls, drv, root, parts):
             obj = super(Path, cls)._from_parsed_parts(drv, root, parts)
-            obj._protocol = ""
-            obj._storage_options = {}
+            _upath_init(obj)
             return obj
 
         @property

From 21f73dc24c5d4d66ed6b55b8ee0527ec1fe26643 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 11:47:55 +0100
Subject: [PATCH 22/50] upath.implementations.local: fix minor issue in init
 for WindowsUPath

---
 upath/implementations/local.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/upath/implementations/local.py b/upath/implementations/local.py
index 4164a5ac..859f9e42 100644
--- a/upath/implementations/local.py
+++ b/upath/implementations/local.py
@@ -149,7 +149,7 @@ def __new__(
         def __init__(
             self, *args, protocol: str | None = None, **storage_options: Any
         ) -> None:
-            super(Path, self).__init__(*args)
+            super(Path, self).__init__()
             self._drv, self._root, self._parts = self._parse_args(args)
             _upath_init(self)
 

From 9c7072f268fc9228c23dd30baed710c2e2171b0e Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 15:02:08 +0100
Subject: [PATCH 23/50] tests: update pathlib3.12 tests to register subclasses

---
 upath/tests/pathlib/test_pathlib_312.py | 69 ++++++++++++++-----------
 upath/tests/utils.py                    | 16 ++++++
 2 files changed, 56 insertions(+), 29 deletions(-)

diff --git a/upath/tests/pathlib/test_pathlib_312.py b/upath/tests/pathlib/test_pathlib_312.py
index 47e2865a..8ee46586 100644
--- a/upath/tests/pathlib/test_pathlib_312.py
+++ b/upath/tests/pathlib/test_pathlib_312.py
@@ -9,6 +9,7 @@
 import stat
 import tempfile
 import unittest
+from contextlib import nullcontext
 from unittest import mock
 
 from ._test_support import import_helper
@@ -16,6 +17,7 @@
 from ._test_support import is_emscripten, is_wasi
 from . import _test_support as os_helper
 from ._test_support import TESTFN, FakePath
+from ..utils import temporary_register
 
 try:
     import grp, pwd
@@ -125,18 +127,25 @@ def test_str_subclass_common(self):
     def test_with_segments_common(self):
         class P(_BasePurePathSubclass, self.cls):
             pass
-        p = P('foo', 'bar', session_id=42)
-        self.assertEqual(42, (p / 'foo').session_id)
-        self.assertEqual(42, ('foo' / p).session_id)
-        self.assertEqual(42, p.joinpath('foo').session_id)
-        self.assertEqual(42, p.with_name('foo').session_id)
-        self.assertEqual(42, p.with_stem('foo').session_id)
-        self.assertEqual(42, p.with_suffix('.foo').session_id)
-        self.assertEqual(42, p.with_segments('foo').session_id)
-        self.assertEqual(42, p.relative_to('foo').session_id)
-        self.assertEqual(42, p.parent.session_id)
-        for parent in p.parents:
-            self.assertEqual(42, parent.session_id)
+
+        if self.cls is UPath:
+            cm = temporary_register("", P)
+        else:
+            cm = nullcontext()
+
+        with cm:
+            p = P('foo', 'bar', session_id=42)
+            self.assertEqual(42, (p / 'foo').session_id)
+            self.assertEqual(42, ('foo' / p).session_id)
+            self.assertEqual(42, p.joinpath('foo').session_id)
+            self.assertEqual(42, p.with_name('foo').session_id)
+            self.assertEqual(42, p.with_stem('foo').session_id)
+            self.assertEqual(42, p.with_suffix('.foo').session_id)
+            self.assertEqual(42, p.with_segments('foo').session_id)
+            self.assertEqual(42, p.relative_to('foo').session_id)
+            self.assertEqual(42, p.parent.session_id)
+            for parent in p.parents:
+                self.assertEqual(42, parent.session_id)
 
     def _get_drive_root_parts(self, parts):
         path = self.cls(*parts)
@@ -1682,23 +1691,25 @@ def test_home(self):
     def test_with_segments(self):
         class P(_BasePurePathSubclass, self.cls):
             pass
-        p = P(BASE, session_id=42)
-        self.assertEqual(42, p.absolute().session_id)
-        self.assertEqual(42, p.resolve().session_id)
-        if not is_wasi:  # WASI has no user accounts.
-            self.assertEqual(42, p.with_segments('~').expanduser().session_id)
-        self.assertEqual(42, (p / 'fileA').rename(p / 'fileB').session_id)
-        self.assertEqual(42, (p / 'fileB').replace(p / 'fileA').session_id)
-        if os_helper.can_symlink():
-            self.assertEqual(42, (p / 'linkA').readlink().session_id)
-        for path in p.iterdir():
-            self.assertEqual(42, path.session_id)
-        for path in p.glob('*'):
-            self.assertEqual(42, path.session_id)
-        for path in p.rglob('*'):
-            self.assertEqual(42, path.session_id)
-        for dirpath, dirnames, filenames in p.walk():
-            self.assertEqual(42, dirpath.session_id)
+
+        with temporary_register("", P):
+            p = P(BASE, session_id=42)
+            self.assertEqual(42, p.absolute().session_id)
+            self.assertEqual(42, p.resolve().session_id)
+            if not is_wasi:  # WASI has no user accounts.
+                self.assertEqual(42, p.with_segments('~').expanduser().session_id)
+            self.assertEqual(42, (p / 'fileA').rename(p / 'fileB').session_id)
+            self.assertEqual(42, (p / 'fileB').replace(p / 'fileA').session_id)
+            if os_helper.can_symlink():
+                self.assertEqual(42, (p / 'linkA').readlink().session_id)
+            for path in p.iterdir():
+                self.assertEqual(42, path.session_id)
+            for path in p.glob('*'):
+                self.assertEqual(42, path.session_id)
+            for path in p.rglob('*'):
+                self.assertEqual(42, path.session_id)
+            for dirpath, dirnames, filenames in p.walk():
+                self.assertEqual(42, dirpath.session_id)
 
     def test_samefile(self):
         fileA_path = os.path.join(BASE, 'fileA')
diff --git a/upath/tests/utils.py b/upath/tests/utils.py
index d25df0c1..463ed0a8 100644
--- a/upath/tests/utils.py
+++ b/upath/tests/utils.py
@@ -1,5 +1,6 @@
 import operator
 import sys
+from contextlib import contextmanager
 
 import pytest
 from fsspec.utils import get_package_version_without_import
@@ -44,3 +45,18 @@ def xfail_if_no_ssl_connection(func):
         return pytest.mark.xfail(reason="No SSL connection")(func)
     else:
         return func
+
+
+@contextmanager
+def temporary_register(protocol, cls):
+    """helper to temporarily register a protocol for testing purposes"""
+    from upath.registry import _registry
+    from upath.registry import get_upath_class
+
+    m = _registry._m.maps[0]
+    try:
+        m[protocol] = cls
+        yield
+    finally:
+        m.clear()
+        get_upath_class.cache_clear()

From f1ea2c8e66acbee6fbc124f514b95eb4591cc367 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 15:04:14 +0100
Subject: [PATCH 24/50] upath.core: don't promote local paths with
 storage_options to FilePath

---
 upath/_protocol.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/upath/_protocol.py b/upath/_protocol.py
index 40dd621f..568dae04 100644
--- a/upath/_protocol.py
+++ b/upath/_protocol.py
@@ -44,8 +44,8 @@ def get_upath_protocol(
         pth_protocol = getattr(pth, "protocol", "")
     else:
         pth_protocol = _match_protocol(str(pth))
-    if storage_options and not protocol and not pth_protocol:
-        protocol = "file"
+    # if storage_options and not protocol and not pth_protocol:
+    #     protocol = "file"
     if protocol and pth_protocol and not pth_protocol.startswith(protocol):
         raise ValueError(
             f"requested protocol {protocol!r} incompatible with {pth_protocol!r}"

From 2ace188dbac1b1f9235a180b75f764e64e86d935 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 15:29:10 +0100
Subject: [PATCH 25/50] upath.implementations.webdav: fix webdav for older
 fsspec versions

---
 upath/implementations/webdav.py | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/upath/implementations/webdav.py b/upath/implementations/webdav.py
index aa953f90..91f452c2 100644
--- a/upath/implementations/webdav.py
+++ b/upath/implementations/webdav.py
@@ -4,10 +4,23 @@
 from urllib.parse import urlsplit
 from urllib.parse import urlunsplit
 
+from fsspec.registry import known_implementations
+from fsspec.registry import register_implementation
+
 import upath.core
 from upath._compat import str_remove_prefix
 from upath._compat import str_remove_suffix
 
+__all__ = [
+    "WebdavPath",
+]
+
+# webdav was only registered in fsspec>=2022.5.0
+if "webdav" not in known_implementations:
+    import webdav4.fsspec
+
+    register_implementation("webdav", webdav4.fsspec.WebdavFileSystem)
+
 
 class WebdavPath(upath.core.UPath):
     __slots__ = ()

From 07703b1b1c41ee40e87779d6b201d0725687451f Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 15:37:26 +0100
Subject: [PATCH 26/50] upath.core: port glob fix for older fsspec version

---
 upath/core.py | 41 ++++++++++++++++++++++++++++++++++++-----
 1 file changed, 36 insertions(+), 5 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index e3be4ad1..fe97a05d 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -28,6 +28,19 @@
 from upath._protocol import get_upath_protocol
 from upath.registry import get_upath_class
 
+_FSSPEC_HAS_WORKING_GLOB = None
+
+
+def _check_fsspec_has_working_glob():
+    global _FSSPEC_HAS_WORKING_GLOB
+    from fsspec.implementations.memory import MemoryFileSystem
+
+    m = type("_M", (MemoryFileSystem,), {"store": {}, "pseudo_dirs": [""]})()
+    m.touch("a.txt")
+    m.touch("f/b.txt")
+    g = _FSSPEC_HAS_WORKING_GLOB = len(m.glob("**/*.txt")) == 2
+    return g
+
 
 class _FSSpecAccessor:
     """this is a compatibility shim and will be removed"""
@@ -443,11 +456,29 @@ def glob(self, pattern: str, *, case_sensitive=None):
             yield self.joinpath(name)
 
     def rglob(self, pattern: str, *, case_sensitive=None):
-        r_path_pattern = self.joinpath("**", pattern).path
-        sep = self._flavour.sep
-        for name in self.fs.glob(r_path_pattern):
-            name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
-            yield self.joinpath(name)
+        if _FSSPEC_HAS_WORKING_GLOB is None:
+            _check_fsspec_has_working_glob()
+
+        if _FSSPEC_HAS_WORKING_GLOB:
+            r_path_pattern = self.joinpath("**", pattern).path
+            sep = self._flavour.sep
+            for name in self.fs.glob(r_path_pattern):
+                name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
+                yield self.joinpath(name)
+
+        else:
+            path_pattern = self.joinpath(pattern).path
+            r_path_pattern = self.joinpath("**", pattern).path
+            sep = self._flavour.sep
+            seen = set()
+            for p in (path_pattern, r_path_pattern):
+                for name in self.fs.glob(p):
+                    name = str_remove_prefix(str_remove_prefix(name, self.path), sep)
+                    if name in seen:
+                        continue
+                    else:
+                        seen.add(name)
+                        yield self.joinpath(name)
 
     @classmethod
     def cwd(cls):

From 2299c82f602b063aec74a19a94fcfdd06779119d Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 20:36:44 +0100
Subject: [PATCH 27/50] upath.core: remove inconsistent typing

---
 upath/core.py | 9 ++-------
 1 file changed, 2 insertions(+), 7 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index fe97a05d..6b506628 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -13,11 +13,6 @@
 from urllib.parse import SplitResult
 from urllib.parse import urlsplit
 
-if sys.version_info >= (3, 11):
-    from typing import Self
-else:
-    Self = Any
-
 from fsspec import AbstractFileSystem
 from fsspec import get_filesystem_class
 
@@ -494,10 +489,10 @@ def home(cls):
         else:
             raise NotImplementedError
 
-    def absolute(self) -> Self:
+    def absolute(self):
         return self
 
-    def resolve(self, strict: bool = False) -> Self:
+    def resolve(self, strict: bool = False):
         _parts = self.parts
 
         # Do not attempt to normalize path if no parts are dots

From bf5c9fd61ff821f7de23fcea7528e5d16733c1ed Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 21:13:14 +0100
Subject: [PATCH 28/50] upath: fix typing issues

---
 noxfile.py                      |  2 --
 pyproject.toml                  |  2 +-
 upath/_flavour.py               | 41 ++++++++++++++++++---------------
 upath/core.py                   |  9 ++++----
 upath/implementations/cloud.py  |  4 ++--
 upath/implementations/local.py  |  3 ++-
 upath/implementations/webdav.py |  2 +-
 upath/registry.py               |  6 ++++-
 8 files changed, 38 insertions(+), 31 deletions(-)

diff --git a/noxfile.py b/noxfile.py
index 65f41eb2..e4ace14c 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -46,8 +46,6 @@ def lint(session: nox.Session) -> None:
 
     args = *(session.posargs or ("--show-diff-on-failure",)), "--all-files"
     session.run("pre-commit", "run", *args)
-    # session.run("python", "-m", "mypy")
-    # session.run("python", "-m", "pylint", *locations)
 
 
 @nox.session
diff --git a/pyproject.toml b/pyproject.toml
index ed54d8a6..efd59939 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -79,7 +79,7 @@ module = "fsspec.*"
 ignore_missing_imports = true
 
 [[tool.mypy.overrides]]
-module = "webdav4.fsspec.*"
+module = "webdav4.*"
 ignore_missing_imports = true
 
 [tool.pylint.format]
diff --git a/upath/_flavour.py b/upath/_flavour.py
index 04216965..34c7a8de 100644
--- a/upath/_flavour.py
+++ b/upath/_flavour.py
@@ -5,9 +5,9 @@
 import posixpath
 import sys
 from functools import lru_cache
-from pathlib import PurePath
 from typing import Any
 from typing import Callable
+from typing import Iterable
 from typing import Union
 from urllib.parse import urlsplit
 
@@ -21,7 +21,7 @@
 from upath._protocol import get_upath_protocol
 from upath._protocol import strip_upath_protocol
 
-PathOrStr: TypeAlias = Union[str, PurePath, os.PathLike]
+PathOrStr: TypeAlias = Union[str, "os.PathLike[str]"]
 
 __all__ = [
     "FSSpecFlavour",
@@ -88,30 +88,31 @@ def __repr__(self):
 
     def join(self, __path: PathOrStr, *paths: PathOrStr) -> str:
         """Join two or more path components, inserting '/' as needed."""
+
+        # [py38-py312] _flavour.join is Callable[[list[str]], str]
         if isinstance(__path, (list, tuple)) and not paths:
-            # py38-py312 _flavour.join is Callable[[list[str]], str]
             if not __path:
                 return ""
-            __path, *paths = __path
+            __path, *paths = __path  # type: ignore
 
-        path = strip_upath_protocol(__path)
-        paths = map(strip_upath_protocol, paths)
+        _path0: str = strip_upath_protocol(__path)
+        _paths: Iterable[str] = map(strip_upath_protocol, paths)
 
         if self.join_like_urljoin:
-            path = str_remove_suffix(path, "/")
+            pth = str_remove_suffix(str(_path0), "/")
             sep = self.sep
-            for b in paths:
+            for b in _paths:
                 if b.startswith(sep):
-                    path = b
-                elif not path:
-                    path += b
+                    pth = b
+                elif not pth:
+                    pth += b
                 else:
-                    path += sep + b
-            joined = path
+                    pth += sep + b
+            joined = pth
         elif self.posixpath_only:
-            joined = posixpath.join(path, *paths)
+            joined = posixpath.join(_path0, *_paths)
         else:
-            joined = os.path.join(path, *paths)
+            joined = os.path.join(_path0, *_paths)
 
         if self.join_prepends_protocol and (protocol := get_upath_protocol(__path)):
             joined = f"{protocol}://{joined}"
@@ -121,24 +122,28 @@ def join(self, __path: PathOrStr, *paths: PathOrStr) -> str:
     def splitroot(self, __path: PathOrStr) -> tuple[str, str, str]:
         """Split a path in the drive, the root and the rest."""
         if self.supports_fragments or self.supports_query_parameters:
-            url = urlsplit(__path)
+            url = urlsplit(str(__path))
             drive = url._replace(path="", query="", fragment="").geturl()
             path = url._replace(scheme="", netloc="").geturl()
             # root = "/" if path.startswith("/") else ""
             root = "/"  # emulate upath.core.UPath < 3.12 behaviour
             return drive, root, str_remove_prefix(path, "/")
 
-        path = strip_upath_protocol(__path, allow_unknown=True)
         if self.supports_netloc:
+            path = strip_upath_protocol(__path, allow_unknown=True)
             protocol = get_upath_protocol(__path)
             if protocol:
                 drive, root, tail = path.partition("/")
                 return drive, root or "/", tail
             else:
                 return "", "", path
+
         elif self.posixpath_only:
+            path = strip_upath_protocol(__path, allow_unknown=True)
             return _get_splitroot(posixpath)(path)
+
         else:
+            path = strip_upath_protocol(__path, allow_unknown=True)
             drv, root, path = _get_splitroot(os.path)(path)
             if os.name == "nt" and not drv:
                 drv = "C:"
@@ -221,7 +226,7 @@ def casefold(self, s: str) -> str:
 
 
 @lru_cache
-def _get_splitroot(mod) -> Callable[[os.PathLike], tuple[str, str, str]]:
+def _get_splitroot(mod) -> Callable[[PathOrStr], tuple[str, str, str]]:
     """return the splitroot function from the given module"""
     if hasattr(mod, "splitroot"):
         return mod.splitroot
diff --git a/upath/core.py b/upath/core.py
index 6b506628..86400f4e 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -9,7 +9,6 @@
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Mapping
-from typing import cast
 from urllib.parse import SplitResult
 from urllib.parse import urlsplit
 
@@ -96,14 +95,14 @@ def __new__(
         if cls is UPath:
             # we called UPath() directly, and want an instance based on the
             # provided or detected protocol (i.e. upath_cls)
-            obj: UPath = cast("UPath", object.__new__(upath_cls))
+            obj: UPath = object.__new__(upath_cls)
             obj._protocol = pth_protocol
 
         elif issubclass(cls, upath_cls):
             # we called a sub- or sub-sub-class of UPath, i.e. S3Path() and the
             # corresponding upath_cls based on protocol is equal-to or a
             # parent-of the cls.
-            obj = cast("UPath", object.__new__(cls))  # type: ignore[unreachable]
+            obj = object.__new__(cls)
             obj._protocol = pth_protocol
 
         elif issubclass(cls, UPath):
@@ -127,7 +126,7 @@ def __new__(
             )
             warnings.warn(msg, DeprecationWarning, stacklevel=2)
 
-            obj = cast("UPath", object.__new__(upath_cls))
+            obj = object.__new__(upath_cls)
             obj._protocol = pth_protocol
 
             upath_cls.__init__(
@@ -145,7 +144,7 @@ def __init__(
         # allow subclasses to customize __init__ arg parsing
         base_options = getattr(self, "_storage_options", {})
         args, protocol, storage_options = type(self)._transform_init_args(
-            args, protocol, {**base_options, **storage_options}
+            args, protocol or self._protocol, {**base_options, **storage_options}
         )
         if self._protocol != protocol and protocol:
             self._protocol = protocol
diff --git a/upath/implementations/cloud.py b/upath/implementations/cloud.py
index 2202a280..a016c9a5 100644
--- a/upath/implementations/cloud.py
+++ b/upath/implementations/cloud.py
@@ -6,7 +6,7 @@
 from upath._flavour import FSSpecFlavour
 
 
-class CloudPath(upath.core.UPath):  # noqa
+class CloudPath(upath.core.UPath):
     __slots__ = ()
     _flavour = FSSpecFlavour(
         join_prepends_protocol=True,
@@ -18,7 +18,7 @@ def __init__(
     ) -> None:
         if "bucket" in storage_options:
             bucket = storage_options.pop("bucket")
-            args = [f"{self._protocol}://{bucket}/", *args]
+            args = (f"{self._protocol}://{bucket}/", *args)
         super().__init__(*args, protocol=protocol, **storage_options)
 
     def mkdir(
diff --git a/upath/implementations/local.py b/upath/implementations/local.py
index 859f9e42..9b05fc1a 100644
--- a/upath/implementations/local.py
+++ b/upath/implementations/local.py
@@ -7,6 +7,7 @@
 from pathlib import PosixPath
 from pathlib import WindowsPath
 from typing import Any
+from typing import Collection
 from typing import MutableMapping
 from urllib.parse import SplitResult
 
@@ -58,7 +59,7 @@ def _set_class_attributes(
     type_dict: MutableMapping[str, Any],
     src: type[Path],
     *,
-    ignore: set[str] = frozenset(_pathlib_py312_ignore),
+    ignore: Collection[str] = frozenset(_pathlib_py312_ignore),
 ) -> None:
     """helper function to assign all methods/attrs from src to a class dict"""
     visited = set()
diff --git a/upath/implementations/webdav.py b/upath/implementations/webdav.py
index 91f452c2..50ee5bc3 100644
--- a/upath/implementations/webdav.py
+++ b/upath/implementations/webdav.py
@@ -43,7 +43,7 @@ def __init__(
                         url._replace(scheme="https", path="")
                     )
         else:
-            args0, argsN = "/", ()
+            args0, argsN = "/", []
         storage_options = {**base_options, **storage_options}
         if "base_url" not in storage_options:
             raise ValueError(
diff --git a/upath/registry.py b/upath/registry.py
index 032bc186..80b3e29b 100644
--- a/upath/registry.py
+++ b/upath/registry.py
@@ -37,6 +37,7 @@
 from functools import lru_cache
 from importlib import import_module
 from importlib.metadata import entry_points
+from typing import TYPE_CHECKING
 from typing import Iterator
 from typing import MutableMapping
 
@@ -78,6 +79,9 @@ class _Registry(MutableMapping[str, "type[upath.UPath]"]):
         "webdav+https": "upath.implementations.webdav.WebdavPath",
     }
 
+    if TYPE_CHECKING:
+        _m: MutableMapping[str, str | type[upath.UPath]]
+
     def __init__(self) -> None:
         if sys.version_info >= (3, 10):
             eps = entry_points(group=_ENTRY_POINT_GROUP)
@@ -90,7 +94,7 @@ def __contains__(self, item: object) -> bool:
         return item in set().union(self._m, self._entries)
 
     def __getitem__(self, item: str) -> type[upath.UPath]:
-        fqn = self._m.get(item)
+        fqn: str | type[upath.UPath] | None = self._m.get(item)
         if fqn is None:
             if item in self._entries:
                 fqn = self._m[item] = self._entries[item].load()

From 7175d726d82599b272a4c435810c8d188a24c81f Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 21:23:49 +0100
Subject: [PATCH 29/50] repo: update pre-commit-config

---
 .pre-commit-config.yaml                  | 18 +++++++++---------
 README.md                                |  2 +-
 noxfile.py                               |  1 +
 upath/__init__.py                        |  1 +
 upath/registry.py                        |  1 +
 upath/tests/cases.py                     |  8 +++++---
 upath/tests/implementations/test_hdfs.py |  1 +
 upath/tests/implementations/test_http.py | 16 ++++++++++------
 upath/tests/implementations/test_s3.py   |  1 +
 9 files changed, 30 insertions(+), 19 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 201e8386..f9c9bb2b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,11 +3,11 @@ default_language_version:
 exclude: ^upath/tests/pathlib/test_pathlib.*\.py|^upath/tests/pathlib/_test_support\.py
 repos:
   - repo: https://github.com/psf/black
-    rev: 23.9.1
+    rev: 24.1.1
     hooks:
       - id: black
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v4.5.0
     hooks:
       - id: check-added-large-files
       - id: check-case-conflict
@@ -25,30 +25,30 @@ repos:
       - id: sort-simple-yaml
       - id: trailing-whitespace
   - repo: https://github.com/codespell-project/codespell
-    rev: v2.2.5
+    rev: v2.2.6
     hooks:
       - id: codespell
         additional_dependencies: ["tomli"]
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.13.0
+    rev: v3.15.0
     hooks:
       - id: pyupgrade
         args: [--py38-plus]
   - repo: https://github.com/PyCQA/isort
-    rev: 5.12.0
+    rev: 5.13.2
     hooks:
       - id: isort
   - repo: https://github.com/pycqa/flake8
-    rev: 6.1.0
+    rev: 7.0.0
     hooks:
       - id: flake8
         additional_dependencies:
-          - flake8-bugbear==23.1.20
-          - flake8-comprehensions==3.10.1
+          - flake8-bugbear==24.1.17
+          - flake8-comprehensions==3.14.0
           - flake8-debugger==4.1.2
           - flake8-string-format==0.3.0
   - repo: https://github.com/pycqa/bandit
-    rev: 1.7.5
+    rev: 1.7.7
     hooks:
       - id: bandit
         args: [-c, pyproject.toml]
diff --git a/README.md b/README.md
index 0ab6c5fd..f840697f 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@ For more examples, see the [example notebook here](notebooks/examples.ipynb)
 
 ### Currently supported filesystems (and schemes)
 
-- `file:` Local filessystem
+- `file:` Local filesystem
 - `memory:` Ephemeral filesystem in RAM
 - `az:`, `adl:`, `abfs:` and `abfss:` Azure Storage (requires `adlfs` to be installed)
 - `http:` and `https:` HTTP(S)-based filesystem
diff --git a/noxfile.py b/noxfile.py
index e4ace14c..53a4eb59 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -1,4 +1,5 @@
 """Automation using nox."""
+
 import glob
 import os
 
diff --git a/upath/__init__.py b/upath/__init__.py
index 3392f65f..1cd4a44f 100644
--- a/upath/__init__.py
+++ b/upath/__init__.py
@@ -1,4 +1,5 @@
 """Pathlib API extended to use fsspec backends."""
+
 import sys
 
 try:
diff --git a/upath/registry.py b/upath/registry.py
index 80b3e29b..f93feeb1 100644
--- a/upath/registry.py
+++ b/upath/registry.py
@@ -27,6 +27,7 @@
     myproto = my_module.submodule:MyPath
 ```
 """
+
 from __future__ import annotations
 
 import os
diff --git a/upath/tests/cases.py b/upath/tests/cases.py
index 64884f01..3955f325 100644
--- a/upath/tests/cases.py
+++ b/upath/tests/cases.py
@@ -50,9 +50,11 @@ def test_expanduser(self):
             "*",
             pytest.param(
                 "**/*.txt",
-                marks=pytest.mark.xfail(reason="requires fsspec>=2023.9.0")
-                if Version(fsspec_version) < Version("2023.9.0")
-                else (),
+                marks=(
+                    pytest.mark.xfail(reason="requires fsspec>=2023.9.0")
+                    if Version(fsspec_version) < Version("2023.9.0")
+                    else ()
+                ),
             ),
         ),
     )
diff --git a/upath/tests/implementations/test_hdfs.py b/upath/tests/implementations/test_hdfs.py
index c2b75cf0..8867cea4 100644
--- a/upath/tests/implementations/test_hdfs.py
+++ b/upath/tests/implementations/test_hdfs.py
@@ -1,5 +1,6 @@
 """see upath/tests/conftest.py for fixtures
 """
+
 import pytest  # noqa: F401
 
 from upath import UPath
diff --git a/upath/tests/implementations/test_http.py b/upath/tests/implementations/test_http.py
index 00a2a02d..c9b2797f 100644
--- a/upath/tests/implementations/test_http.py
+++ b/upath/tests/implementations/test_http.py
@@ -48,15 +48,19 @@ def test_mkdir(self):
             "*.txt",
             pytest.param(
                 "*",
-                marks=pytest.mark.xfail(reason="requires fsspec<=2023.10.0")
-                if Version(fsspec_version) > Version("2023.10.0")
-                else (),
+                marks=(
+                    pytest.mark.xfail(reason="requires fsspec<=2023.10.0")
+                    if Version(fsspec_version) > Version("2023.10.0")
+                    else ()
+                ),
             ),
             pytest.param(
                 "**/*.txt",
-                marks=pytest.mark.xfail(reason="requires fsspec>=2023.9.0")
-                if Version(fsspec_version) < Version("2023.9.0")
-                else (),
+                marks=(
+                    pytest.mark.xfail(reason="requires fsspec>=2023.9.0")
+                    if Version(fsspec_version) < Version("2023.9.0")
+                    else ()
+                ),
             ),
         ),
     )
diff --git a/upath/tests/implementations/test_s3.py b/upath/tests/implementations/test_s3.py
index ae5d10b2..9b57f013 100644
--- a/upath/tests/implementations/test_s3.py
+++ b/upath/tests/implementations/test_s3.py
@@ -1,5 +1,6 @@
 """see upath/tests/conftest.py for fixtures
 """
+
 import fsspec
 import pytest  # noqa: F401
 

From 5d09e51d020ec6824f6b6a185063d413b890257b Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 21:37:34 +0100
Subject: [PATCH 30/50] readme: adjust LocalPath -> FilePath

---
 README.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index f840697f..955f9c74 100644
--- a/README.md
+++ b/README.md
@@ -99,7 +99,7 @@ flowchart TB
   U(UPath)
   UP(PosixUPath)
   UW(WindowsUPath)
-  UL(LocalPath)
+  UL(FilePath)
   US3(S3Path)
   UH(HttpPath)
   UO(...Path)
@@ -168,13 +168,13 @@ register_implementation(my_protocol, MyPath)
 
 #### Registration via entry points
 
-```toml
+```
 # pyproject.toml
 [project.entry-points."unversal_pathlib.implementations"]
 myproto = "my_module.submodule:MyPath"
 ```
 
-```ini
+```
 # setup.cfg
 [options.entry_points]
 universal_pathlib.implementations =

From 28d1f49d645b3e62a32c5a01ff9cd8a484665b32 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 23:28:52 +0100
Subject: [PATCH 31/50] upath: deprecate accessors more gracefully

---
 upath/_compat.py                | 98 +++++++++++++++++++++++++++++++++
 upath/core.py                   | 31 +++++++----
 upath/implementations/cloud.py  | 20 +++++--
 upath/implementations/hdfs.py   | 10 +++-
 upath/implementations/http.py   | 16 ++++--
 upath/implementations/local.py  |  4 +-
 upath/implementations/memory.py | 10 +++-
 upath/implementations/webdav.py |  9 ++-
 8 files changed, 170 insertions(+), 28 deletions(-)

diff --git a/upath/_compat.py b/upath/_compat.py
index f26b4d78..94bbfeff 100644
--- a/upath/_compat.py
+++ b/upath/_compat.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import ntpath
 import os
 import posixpath
@@ -6,11 +8,20 @@
 from collections.abc import Sequence
 from pathlib import Path
 from pathlib import PurePath
+from typing import TYPE_CHECKING
+from typing import Any
+from urllib.parse import SplitResult
+
+from fsspec import get_filesystem_class
+
+if TYPE_CHECKING:
+    from upath import UPath
 
 __all__ = [
     "PathlibPathShim",
     "str_remove_prefix",
     "str_remove_suffix",
+    "FSSpecAccessorShim",
 ]
 
 
@@ -381,3 +392,90 @@ def str_remove_prefix(s: str, prefix: str) -> str:
             return s[len(prefix) :]
         else:
             return s
+
+
+class FSSpecAccessorShim:
+    """this is a compatibility shim and will be removed"""
+
+    def __init__(self, parsed_url: SplitResult | None, **kwargs: Any) -> None:
+        if parsed_url and parsed_url.scheme:
+            cls = get_filesystem_class(parsed_url.scheme)
+            url_kwargs = cls._get_kwargs_from_urls(parsed_url.geturl())
+        else:
+            cls = get_filesystem_class(None)
+            url_kwargs = {}
+        url_kwargs.update(kwargs)
+        self._fs = cls(**url_kwargs)
+
+    def __init_subclass__(cls, **kwargs):
+        warnings.warn(
+            "All accessors have been deprecated. Please follow the"
+            " universal_pathlib v0.2.0 migration guide at"
+            " https://github.com/fsspec/universal_pathlib for more"
+            " information.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+
+    @classmethod
+    def from_path(cls, path: UPath) -> FSSpecAccessorShim:
+        """internal accessor for backwards compatibility"""
+        obj = object.__new__(cls)
+        obj._fs = path.fs
+        return obj
+
+    def _format_path(self, path: UPath) -> str:
+        return path.path
+
+    def open(self, path, mode="r", *args, **kwargs):
+        return path.fs.open(self._format_path(path), mode, *args, **kwargs)
+
+    def stat(self, path, **kwargs):
+        return path.fs.stat(self._format_path(path), **kwargs)
+
+    def listdir(self, path, **kwargs):
+        p_fmt = self._format_path(path)
+        contents = path.fs.listdir(p_fmt, **kwargs)
+        if len(contents) == 0 and not path.fs.isdir(p_fmt):
+            raise NotADirectoryError(str(self))
+        elif (
+            len(contents) == 1
+            and contents[0]["name"] == p_fmt
+            and contents[0]["type"] == "file"
+        ):
+            raise NotADirectoryError(str(self))
+        return contents
+
+    def glob(self, _path, path_pattern, **kwargs):
+        return _path.fs.glob(self._format_path(path_pattern), **kwargs)
+
+    def exists(self, path, **kwargs):
+        return path.fs.exists(self._format_path(path), **kwargs)
+
+    def info(self, path, **kwargs):
+        return path.fs.info(self._format_path(path), **kwargs)
+
+    def rm(self, path, recursive, **kwargs):
+        return path.fs.rm(self._format_path(path), recursive=recursive, **kwargs)
+
+    def mkdir(self, path, create_parents=True, **kwargs):
+        return path.fs.mkdir(
+            self._format_path(path), create_parents=create_parents, **kwargs
+        )
+
+    def makedirs(self, path, exist_ok=False, **kwargs):
+        return path.fs.makedirs(self._format_path(path), exist_ok=exist_ok, **kwargs)
+
+    def touch(self, path, **kwargs):
+        return path.fs.touch(self._format_path(path), **kwargs)
+
+    def mv(self, path, target, recursive=False, maxdepth=None, **kwargs):
+        if hasattr(target, "_accessor"):
+            target = target._accessor._format_path(target)
+        return path.fs.mv(
+            self._format_path(path),
+            target,
+            recursive=recursive,
+            maxdepth=maxdepth,
+            **kwargs,
+        )
diff --git a/upath/core.py b/upath/core.py
index 86400f4e..b1945a4d 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -9,12 +9,12 @@
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Mapping
-from urllib.parse import SplitResult
 from urllib.parse import urlsplit
 
 from fsspec import AbstractFileSystem
 from fsspec import get_filesystem_class
 
+from upath._compat import FSSpecAccessorShim
 from upath._compat import PathlibPathShim
 from upath._compat import str_remove_prefix
 from upath._compat import str_remove_suffix
@@ -22,6 +22,9 @@
 from upath._protocol import get_upath_protocol
 from upath.registry import get_upath_class
 
+__all__ = ["UPath"]
+
+
 _FSSPEC_HAS_WORKING_GLOB = None
 
 
@@ -36,18 +39,15 @@ def _check_fsspec_has_working_glob():
     return g
 
 
-class _FSSpecAccessor:
-    """this is a compatibility shim and will be removed"""
-
-    def __init__(self, parsed_url: SplitResult | None, **kwargs: Any) -> None:
-        pass
-
-
 def _make_instance(cls, args, kwargs):
     """helper for pickling UPath instances"""
     return cls(*args, **kwargs)
 
 
+# accessors are deprecated
+_FSSpecAccessor = FSSpecAccessorShim
+
+
 class UPath(PathlibPathShim, Path):
     __slots__ = (
         "_protocol",
@@ -212,11 +212,11 @@ def __init_subclass__(cls, **kwargs):
         # guess which parts the user subclass is customizing
         has_custom_legacy_accessor = (
             accessor_cls is not None
-            and issubclass(accessor_cls, _FSSpecAccessor)
-            and accessor_cls is not _FSSpecAccessor
+            and issubclass(accessor_cls, FSSpecAccessorShim)
+            and accessor_cls is not FSSpecAccessorShim
         )
         has_customized_fs_instantiation = (
-            accessor_cls.__init__ is not _FSSpecAccessor.__init__
+            accessor_cls.__init__ is not FSSpecAccessorShim.__init__
             or hasattr(accessor_cls, "_fs")
         )
 
@@ -278,6 +278,15 @@ def _kwargs(self):
     def _url(self):  # todo: deprecate
         return urlsplit(self.as_posix())
 
+    @property
+    def _accessor(self):
+        warnings.warn(
+            "use UPath.fs instead of UPath._accessor",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return FSSpecAccessorShim.from_path(self)
+
     # === pathlib.PurePath ============================================
 
     def __reduce__(self):
diff --git a/upath/implementations/cloud.py b/upath/implementations/cloud.py
index a016c9a5..3ad263ce 100644
--- a/upath/implementations/cloud.py
+++ b/upath/implementations/cloud.py
@@ -2,13 +2,25 @@
 
 from typing import Any
 
-import upath.core
-from upath._flavour import FSSpecFlavour
+from upath._compat import FSSpecAccessorShim as _FSSpecAccessorShim
+from upath._flavour import FSSpecFlavour as _FSSpecFlavour
+from upath.core import UPath
 
+__all__ = [
+    "CloudPath",
+    "GCSPath",
+    "S3Path",
+    "AzurePath",
+]
 
-class CloudPath(upath.core.UPath):
+
+# accessors are deprecated
+_CloudAccessor = _FSSpecAccessorShim
+
+
+class CloudPath(UPath):
     __slots__ = ()
-    _flavour = FSSpecFlavour(
+    _flavour = _FSSpecFlavour(
         join_prepends_protocol=True,
         supports_netloc=True,
     )
diff --git a/upath/implementations/hdfs.py b/upath/implementations/hdfs.py
index 34d12723..55e553c8 100644
--- a/upath/implementations/hdfs.py
+++ b/upath/implementations/hdfs.py
@@ -1,9 +1,15 @@
 from __future__ import annotations
 
-import upath.core
+from upath._compat import FSSpecAccessorShim as _FSSpecAccessorShim
+from upath.core import UPath
 
+__all__ = ["HDFSPath"]
 
-class HDFSPath(upath.core.UPath):
+# accessors are deprecated
+_HDFSAccessor = _FSSpecAccessorShim
+
+
+class HDFSPath(UPath):
     __slots__ = ()
 
     def mkdir(self, mode=0o777, parents=False, exist_ok=False):
diff --git a/upath/implementations/http.py b/upath/implementations/http.py
index 46d36024..0d0dc21f 100644
--- a/upath/implementations/http.py
+++ b/upath/implementations/http.py
@@ -4,12 +4,18 @@
 
 from fsspec.asyn import sync
 
-import upath.core
-from upath._flavour import FSSpecFlavour
+from upath._compat import FSSpecAccessorShim as _FSSpecAccessorShim
+from upath._flavour import FSSpecFlavour as _FSSpecFlavour
+from upath.core import UPath
 
+__all__ = ["HTTPPath"]
 
-class HTTPPath(upath.core.UPath):  # noqa
-    _flavour = FSSpecFlavour(
+# accessors are deprecated
+_HTTPAccessor = _FSSpecAccessorShim
+
+
+class HTTPPath(UPath):
+    _flavour = _FSSpecFlavour(
         join_like_urljoin=True,
         supports_empty_parts=True,
         supports_netloc=True,
@@ -22,7 +28,7 @@ def root(self) -> str:
         return super().root or "/"
 
     def __str__(self):
-        return super(upath.core.UPath, self).__str__()
+        return super(UPath, self).__str__()
 
     def is_file(self):
         try:
diff --git a/upath/implementations/local.py b/upath/implementations/local.py
index 9b05fc1a..1d6203d0 100644
--- a/upath/implementations/local.py
+++ b/upath/implementations/local.py
@@ -11,7 +11,7 @@
 from typing import MutableMapping
 from urllib.parse import SplitResult
 
-from upath._flavour import FSSpecFlavour
+from upath._flavour import FSSpecFlavour as _FSSpecFlavour
 from upath.core import UPath
 
 __all__ = [
@@ -24,7 +24,7 @@
 
 class LocalPath(UPath):
     __slots__ = ()
-    _flavour = FSSpecFlavour(
+    _flavour = _FSSpecFlavour(
         posixpath_only=False,
     )
 
diff --git a/upath/implementations/memory.py b/upath/implementations/memory.py
index b5fbac85..7169cd42 100644
--- a/upath/implementations/memory.py
+++ b/upath/implementations/memory.py
@@ -1,9 +1,15 @@
 from __future__ import annotations
 
-import upath.core
+from upath._compat import FSSpecAccessorShim as _FSSpecAccessorShim
+from upath.core import UPath
 
+__all__ = ["MemoryPath"]
 
-class MemoryPath(upath.core.UPath):  # noqa
+# accessors are deprecated
+_MemoryAccessor = _FSSpecAccessorShim
+
+
+class MemoryPath(UPath):
     def iterdir(self):
         if not self.is_dir():
             raise NotADirectoryError(str(self))
diff --git a/upath/implementations/webdav.py b/upath/implementations/webdav.py
index 50ee5bc3..941d6f7b 100644
--- a/upath/implementations/webdav.py
+++ b/upath/implementations/webdav.py
@@ -7,9 +7,10 @@
 from fsspec.registry import known_implementations
 from fsspec.registry import register_implementation
 
-import upath.core
+from upath._compat import FSSpecAccessorShim as _FSSpecAccessorShim
 from upath._compat import str_remove_prefix
 from upath._compat import str_remove_suffix
+from upath.core import UPath
 
 __all__ = [
     "WebdavPath",
@@ -22,7 +23,11 @@
     register_implementation("webdav", webdav4.fsspec.WebdavFileSystem)
 
 
-class WebdavPath(upath.core.UPath):
+# accessors are deprecated
+_WebdavAccessor = _FSSpecAccessorShim
+
+
+class WebdavPath(UPath):
     __slots__ = ()
 
     def __init__(

From 6043c50025082d614f1cff88e3fb077b93366d5d Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Mon, 5 Feb 2024 23:42:44 +0100
Subject: [PATCH 32/50] upath.core: _accessor shim can't use property

---
 upath/core.py | 18 ++++++++++--------
 1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index b1945a4d..ff58b519 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -278,14 +278,16 @@ def _kwargs(self):
     def _url(self):  # todo: deprecate
         return urlsplit(self.as_posix())
 
-    @property
-    def _accessor(self):
-        warnings.warn(
-            "use UPath.fs instead of UPath._accessor",
-            DeprecationWarning,
-            stacklevel=2,
-        )
-        return FSSpecAccessorShim.from_path(self)
+    def __getattr__(self, item):
+        if item == "_accessor":
+            warnings.warn(
+                "use UPath.fs instead of UPath._accessor",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            return FSSpecAccessorShim.from_path(self)
+        else:
+            raise AttributeError(item)
 
     # === pathlib.PurePath ============================================
 

From c893666125a4c3410d9c5d23c0a58c50660e034f Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 00:21:59 +0100
Subject: [PATCH 33/50] upath.implementations.local: fix iterdir for fsspec
 local paths when called on a file

---
 upath/implementations/local.py | 10 ++++++++++
 upath/tests/test_core.py       |  7 +++++++
 2 files changed, 17 insertions(+)

diff --git a/upath/implementations/local.py b/upath/implementations/local.py
index 1d6203d0..dd7dcce2 100644
--- a/upath/implementations/local.py
+++ b/upath/implementations/local.py
@@ -11,6 +11,9 @@
 from typing import MutableMapping
 from urllib.parse import SplitResult
 
+from fsspec import __version__ as fsspec_version
+from packaging.version import Version
+
 from upath._flavour import FSSpecFlavour as _FSSpecFlavour
 from upath.core import UPath
 
@@ -21,6 +24,8 @@
     "WindowsUPath",
 ]
 
+_LISTDIR_WORKS_ON_FILES = Version(fsspec_version) >= Version("2024.2.0")
+
 
 class LocalPath(UPath):
     __slots__ = ()
@@ -43,6 +48,11 @@ def _url(self):
 class FilePath(LocalPath):
     __slots__ = ()
 
+    def iterdir(self):
+        if _LISTDIR_WORKS_ON_FILES and self.is_file():
+            raise NotADirectoryError(f"{self}")
+        return super().iterdir()
+
 
 _pathlib_py312_ignore = {
     "__slots__",
diff --git a/upath/tests/test_core.py b/upath/tests/test_core.py
index df1816db..9baf6e6d 100644
--- a/upath/tests/test_core.py
+++ b/upath/tests/test_core.py
@@ -15,6 +15,7 @@
 from .cases import BaseTests
 from .utils import only_on_windows
 from .utils import skip_on_windows
+from .utils import xfail_if_version
 
 
 @skip_on_windows
@@ -68,6 +69,12 @@ def test_home(self):
         assert isinstance(pth, pathlib.Path)
         assert isinstance(pth, UPath)
 
+    @xfail_if_version("fsspec", reason="", ge="2024.2.0")
+    def test_iterdir_no_dir(self):
+        # the mock filesystem is basically just LocalFileSystem,
+        # so this test would need to have an iterdir fix.
+        super().test_iterdir_no_dir()
+
 
 def test_multiple_backend_paths(local_testdir):
     path = "s3://bucket/"

From f17782b80ed9e660b9aee39658d7b04b69cd9acc Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 12:36:08 +0100
Subject: [PATCH 34/50] upath._compat: fix warnings._deprecated is missing on
 older pythons

---
 upath/_compat.py | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/upath/_compat.py b/upath/_compat.py
index 94bbfeff..fdc2c8f0 100644
--- a/upath/_compat.py
+++ b/upath/_compat.py
@@ -254,10 +254,12 @@ def relative_to(self, other, /, *_deprecated, walk_up=False):
                 msg = (
                     "support for supplying more than one positional argument "
                     "to pathlib.PurePath.relative_to() is deprecated and "
-                    "scheduled for removal in Python {remove}"
+                    "scheduled for removal in Python 3.14"
                 )
-                warnings._deprecated(
-                    "pathlib.PurePath.relative_to(*args)", msg, remove=(3, 14)
+                warnings.warn(
+                    f"pathlib.PurePath.relative_to(*args) {msg}",
+                    DeprecationWarning,
+                    stacklevel=2,
                 )
             other = self.with_segments(other, *_deprecated)
             for step, path in enumerate([other] + list(other.parents)):  # noqa: B007
@@ -281,10 +283,12 @@ def is_relative_to(self, other, /, *_deprecated):
                 msg = (
                     "support for supplying more than one argument to "
                     "pathlib.PurePath.is_relative_to() is deprecated and "
-                    "scheduled for removal in Python {remove}"
+                    "scheduled for removal in Python 3.14"
                 )
-                warnings._deprecated(
-                    "pathlib.PurePath.is_relative_to(*args)", msg, remove=(3, 14)
+                warnings.warn(
+                    f"pathlib.PurePath.is_relative_to(*args) {msg}",
+                    DeprecationWarning,
+                    stacklevel=2,
                 )
             other = self.with_segments(other, *_deprecated)
             return other == self or other in self.parents

From e46317416d0046022290ef303b96705cbc10a215 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 12:41:10 +0100
Subject: [PATCH 35/50] upath.core: try to provide backwards compatibility for
 custom __new__ methods

---
 upath/core.py                  | 43 +++++++++++++++++++++++++++++++++-
 upath/implementations/cloud.py | 11 ++++++---
 2 files changed, 50 insertions(+), 4 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index ff58b519..e23f8093 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -60,6 +60,7 @@ class UPath(PathlibPathShim, Path):
         _storage_options: dict[str, Any]
         _fs_cached: AbstractFileSystem
 
+    _protocol_dispatch: bool | None = None
     _flavour = FSSpecFlavour()
 
     def __new__(
@@ -90,6 +91,8 @@ def __new__(
         upath_cls = get_upath_class(protocol=pth_protocol)
         if upath_cls is None:
             raise ValueError(f"Unsupported filesystem: {pth_protocol!r}")
+        if cls._protocol_dispatch is not None and not cls._protocol_dispatch:
+            upath_cls = cls
 
         # create a new instance
         if cls is UPath:
@@ -182,6 +185,8 @@ def __init__(
                 pass
 
         # fill ._raw_paths
+        if hasattr(self, "_raw_paths"):
+            return
         super().__init__(*args)
 
     # === upath.UPath only ============================================
@@ -207,9 +212,25 @@ def storage_options(self) -> Mapping[str, Any]:
     def __init_subclass__(cls, **kwargs):
         """provide a clean migration path for custom user subclasses"""
 
+        # Check if the user subclass has a custom `__new__` method
+        has_custom_new_method = cls.__new__ is not UPath.__new__
+
+        if has_custom_new_method and cls._protocol_dispatch is None:
+            warnings.warn(
+                "Detected a customized `__new__` method in subclass"
+                f" {cls.__name__!r}. Protocol dispatch will be disabled"
+                " for this subclass. Please follow the"
+                " universal_pathlib==0.2.0 migration guide at"
+                " https://github.com/fsspec/universal_pathlib for more"
+                " information.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            cls._protocol_dispatch = False
+
+        # Check if the user subclass has defined a custom accessor class
         accessor_cls = getattr(cls, "_default_accessor", None)
 
-        # guess which parts the user subclass is customizing
         has_custom_legacy_accessor = (
             accessor_cls is not None
             and issubclass(accessor_cls, FSSpecAccessorShim)
@@ -289,6 +310,26 @@ def __getattr__(self, item):
         else:
             raise AttributeError(item)
 
+    @classmethod
+    def _from_parts(cls, parts, **kwargs):
+        warnings.warn(
+            "UPath._from_parts is deprecated and should not be used."
+            " Please follow the universal_pathlib==0.2.0 migration guide at"
+            " https://github.com/fsspec/universal_pathlib for more"
+            " information.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        parsed_url = kwargs.pop("url", None)
+        if parsed_url:
+            if protocol := parsed_url.scheme:
+                kwargs["protocol"] = protocol
+            if netloc := parsed_url.netloc:
+                kwargs["netloc"] = netloc
+        obj = UPath.__new__(cls, parts, **kwargs)
+        obj.__init__(*parts, **kwargs)
+        return obj
+
     # === pathlib.PurePath ============================================
 
     def __reduce__(self):
diff --git a/upath/implementations/cloud.py b/upath/implementations/cloud.py
index 3ad263ce..427deb7b 100644
--- a/upath/implementations/cloud.py
+++ b/upath/implementations/cloud.py
@@ -28,9 +28,14 @@ class CloudPath(UPath):
     def __init__(
         self, *args, protocol: str | None = None, **storage_options: Any
     ) -> None:
-        if "bucket" in storage_options:
-            bucket = storage_options.pop("bucket")
-            args = (f"{self._protocol}://{bucket}/", *args)
+        for key in ["bucket", "netloc"]:
+            bucket = storage_options.pop(key, None)
+            if bucket:
+                if args[0].startswith("/"):
+                    args = (f"{self._protocol}://{bucket}{args[0]}", *args[1:])
+                else:
+                    args = (f"{self._protocol}://{bucket}/", *args)
+                break
         super().__init__(*args, protocol=protocol, **storage_options)
 
     def mkdir(

From 60ebee3ed805911d9b40e5c25ee5a5a57d177c26 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 12:44:37 +0100
Subject: [PATCH 36/50] upath: align all migration related deprecation warnings

---
 upath/_compat.py |  4 ++--
 upath/core.py    | 21 ++++++++++++++++-----
 2 files changed, 18 insertions(+), 7 deletions(-)

diff --git a/upath/_compat.py b/upath/_compat.py
index fdc2c8f0..f9c655f3 100644
--- a/upath/_compat.py
+++ b/upath/_compat.py
@@ -413,8 +413,8 @@ def __init__(self, parsed_url: SplitResult | None, **kwargs: Any) -> None:
 
     def __init_subclass__(cls, **kwargs):
         warnings.warn(
-            "All accessors have been deprecated. Please follow the"
-            " universal_pathlib v0.2.0 migration guide at"
+            "All _FSSpecAccessor subclasses have been deprecated. "
+            " Please follow the universal_pathlib==0.2.0 migration guide at"
             " https://github.com/fsspec/universal_pathlib for more"
             " information.",
             DeprecationWarning,
diff --git a/upath/core.py b/upath/core.py
index e23f8093..0e071211 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -244,9 +244,12 @@ def __init_subclass__(cls, **kwargs):
         if has_custom_legacy_accessor and has_customized_fs_instantiation:
             warnings.warn(
                 "Detected a customized `__init__` method or `_fs` attribute"
-                " in the provided `_FSSpecAccessor` subclass. It is recommended to"
-                " instead override the `_fs_factory` classmethod to customize"
-                " filesystem instantiation.",
+                f" in the provided `_FSSpecAccessor` subclass of {cls.__name__!r}."
+                " It is recommended to instead override the `UPath._fs_factory`"
+                " classmethod to customize filesystem instantiation. Please follow"
+                " the universal_pathlib==0.2.0 migration guide at"
+                " https://github.com/fsspec/universal_pathlib for more"
+                " information.",
                 DeprecationWarning,
                 stacklevel=2,
             )
@@ -289,7 +292,11 @@ def path(self) -> str:
     @property
     def _kwargs(self):
         warnings.warn(
-            "use UPath.storage_options instead of UPath._kwargs",
+            "UPath._kwargs is deprecated. Please use"
+            " UPath.storage_options instead. Follow the"
+            " universal_pathlib==0.2.0 migration guide at"
+            " https://github.com/fsspec/universal_pathlib for more"
+            " information.",
             DeprecationWarning,
             stacklevel=2,
         )
@@ -302,7 +309,11 @@ def _url(self):  # todo: deprecate
     def __getattr__(self, item):
         if item == "_accessor":
             warnings.warn(
-                "use UPath.fs instead of UPath._accessor",
+                "UPath._accessor is deprecated. Please use"
+                " UPath.fs instead. Follow the"
+                " universal_pathlib==0.2.0 migration guide at"
+                " https://github.com/fsspec/universal_pathlib for more"
+                " information.",
                 DeprecationWarning,
                 stacklevel=2,
             )

From 43859069de667557d880aa75c6b7682e61b066c4 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 12:54:37 +0100
Subject: [PATCH 37/50] upath.core: add comment explainin _protocol_dispatch

---
 upath/core.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/upath/core.py b/upath/core.py
index 0e071211..6fe63df8 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -92,6 +92,13 @@ def __new__(
         if upath_cls is None:
             raise ValueError(f"Unsupported filesystem: {pth_protocol!r}")
         if cls._protocol_dispatch is not None and not cls._protocol_dispatch:
+            # user subclasses can request to disable protocol dispatch
+            # by setting MyUPathSubclass._protocol_dispatch to `False`.
+            # This will effectively ignore the registered UPath
+            # implementations and return an instance of MyUPathSubclass.
+            # This can be useful if a subclass wants to extend the UPath
+            # api, and it is fine to rely on the default implementation
+            # for all supported user protocols.
             upath_cls = cls
 
         # create a new instance

From 3d9a2d254ba1bb40318170a8aa977efcd35262e2 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 13:30:00 +0100
Subject: [PATCH 38/50] tests: some subclassing tests in the pathlib3.12 test
 suite xpass

---
 upath/tests/pathlib/test_pathlib_312.py | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/upath/tests/pathlib/test_pathlib_312.py b/upath/tests/pathlib/test_pathlib_312.py
index 8ee46586..0b2f80a9 100644
--- a/upath/tests/pathlib/test_pathlib_312.py
+++ b/upath/tests/pathlib/test_pathlib_312.py
@@ -2968,8 +2968,9 @@ def test_glob_empty_pattern(self):
         with self.assertRaisesRegex(ValueError, 'Unacceptable pattern'):
             list(p.glob(''))
 
-    @pytest.mark.xfail(reason="subclassing UPath directly for Posix and Windows paths requires protocol registration")
     def test_with_segments(self):
+        if self.cls is UPath:
+            pytest.skip(reason="")
         super().test_with_segments()
 
 @only_posix
@@ -3276,14 +3277,12 @@ def check():
 
 
 class PathSubclassTest(_BasePathTest, unittest.TestCase):
-    class cls(UPath):
-        cwd = UPath.cwd
-        home = UPath.home
+    class cls(WindowsUPath if os.name == 'nt' else PosixUPath):
+        pass
 
     # repr() roundtripping is not supported in custom subclass.
     test_repr_roundtrips = None
 
-    @pytest.mark.xfail(reason="subsubclassing UPath directly for Posix and Windows paths requires protocol registration")
     def test_with_segments(self):
         super().test_with_segments()
 

From 40692274ada11a0ed309bee84761263671335fd3 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 13:40:50 +0100
Subject: [PATCH 39/50] tests: test the migration compat code with a GitHub
 implementation

---
 .../tests/third_party/test_migration_py312.py | 99 +++++++++++++++++++
 1 file changed, 99 insertions(+)
 create mode 100644 upath/tests/third_party/test_migration_py312.py

diff --git a/upath/tests/third_party/test_migration_py312.py b/upath/tests/third_party/test_migration_py312.py
new file mode 100644
index 00000000..c666f3ae
--- /dev/null
+++ b/upath/tests/third_party/test_migration_py312.py
@@ -0,0 +1,99 @@
+from os import getenv
+
+import pytest
+
+from upath import UPath
+from upath.registry import get_upath_class
+from upath.registry import register_implementation
+
+
+@pytest.fixture(scope="function")
+def clean_registry():
+    from upath.registry import _registry
+
+    try:
+        yield
+    finally:
+        _registry._m.maps.clear()
+        get_upath_class.cache_clear()
+
+
+@pytest.fixture(scope="function")
+def github_subclass_old_style(clean_registry):
+    # GitHubPath code from:
+    # https://github.com/juftin/textual-universal-directorytree/blob/110770f2ee40ab5afff7eade635caad644d80848/textual_universal_directorytree/alternate_paths.py#L15-L27
+
+    from upath.core import _FSSpecAccessor
+
+    class _GitHubAccessor(_FSSpecAccessor):
+        def __init__(self, *args, **kwargs):
+            token = getenv("GITHUB_TOKEN")
+            if token is not None:
+                kwargs.update({"username": "Bearer", "token": token})
+            super().__init__(*args, **kwargs)
+
+    class GitHubPath(UPath):
+        _default_accessor = _GitHubAccessor
+
+        def __new__(cls, *args, **kwargs):
+            file_path = cls.handle_github_url(*args[0:1], storage_options=kwargs)
+            return super().__new__(cls, file_path, *args[1:], **kwargs)
+
+        @property
+        def path(self):
+            return super().path.strip("/")
+
+        @property
+        def name(self):
+            if self.path == "":
+                org = self._accessor._fs.org
+                repo = self._accessor._fs.repo
+                sha = self._accessor._fs.storage_options["sha"]
+                github_name = f"{org}:{repo}@{sha}"
+                return github_name
+            else:
+                return super().name
+
+        @classmethod
+        def handle_github_url(cls, url, storage_options):
+            import requests  # type: ignore[import]
+
+            url = str(url)
+            gitub_prefix = "github://"
+            if gitub_prefix in url and "@" not in url:
+                _, user_password = url.split("github://")
+                if "org" in storage_options and "repo" in storage_options:
+                    org = storage_options["org"]
+                    repo = storage_options["repo"]
+                    _, *args = user_password.rpartition(":")[2].split("/")
+                else:
+                    org, repo_str = user_password.split(":")
+                    repo, *args = repo_str.split("/")
+            elif gitub_prefix in url and "@" in url:
+                return url
+            else:
+                raise ValueError(f"Invalid GitHub URL: {url}")
+            token = getenv("GITHUB_TOKEN")
+            auth = {"auth": ("Bearer", token)} if token is not None else {}
+            resp = requests.get(
+                f"https://api.github.com/repos/{org}/{repo}",
+                headers={"Accept": "application/vnd.github.v3+json"},
+                **auth,  # type: ignore[arg-type]
+            )
+            resp.raise_for_status()
+            default_branch = resp.json()["default_branch"]
+            arg_str = "/".join(args)
+            github_uri = (
+                f"{gitub_prefix}{org}:{repo}@{default_branch}/{arg_str}".rstrip("/")
+            )
+            return github_uri
+
+    register_implementation("github", GitHubPath, clobber=True)
+
+
+def test_migration_for_github_subclass(github_subclass_old_style):
+
+    readme = UPath("github://fsspec:universal_pathlib@main/README.md").read_text()
+    assert "universal_pathlib" in readme
+    rst_files = list(UPath("github://fsspec:universal_pathlib@main/").glob("*.rst"))
+    assert len(rst_files) == 2

From 7fb8f1868c4241f424d23a800625b88200095882 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 13:50:33 +0100
Subject: [PATCH 40/50] tests: add comment regarding webdav test

---
 upath/tests/implementations/test_webdav.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/upath/tests/implementations/test_webdav.py b/upath/tests/implementations/test_webdav.py
index 85572abe..23693e2e 100644
--- a/upath/tests/implementations/test_webdav.py
+++ b/upath/tests/implementations/test_webdav.py
@@ -3,7 +3,6 @@
 from upath import UPath
 
 from ..cases import BaseTests
-from ..utils import xfail_if_version
 
 
 class TestUPathWebdav(BaseTests):
@@ -22,6 +21,9 @@ def test_storage_options(self):
         assert storage_options == self.path.fs.storage_options
         assert base_url == self.path.fs.client.base_url
 
-    @xfail_if_version("fsspec", lt="2022.5.0", reason="requires fsspec>=2022.5.0")
     def test_read_with_fsspec(self):
+        # this test used to fail with fsspec<2022.5.0 because webdav was not
+        # registered in fsspec. But when UPath(webdav_fixture) is called, to
+        # run the BaseTests, the upath.implementations.webdav module is
+        # imported, which registers the webdav implementation in fsspec.
         super().test_read_with_fsspec()

From 8e0a3892e6f032589553bedbf23d421b25219c16 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 14:08:44 +0100
Subject: [PATCH 41/50] tests: run github migration test only if GITHUB_TOKEN
 in env

---
 upath/tests/third_party/test_migration_py312.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/upath/tests/third_party/test_migration_py312.py b/upath/tests/third_party/test_migration_py312.py
index c666f3ae..de2477e2 100644
--- a/upath/tests/third_party/test_migration_py312.py
+++ b/upath/tests/third_party/test_migration_py312.py
@@ -1,3 +1,4 @@
+import os
 from os import getenv
 
 import pytest
@@ -91,6 +92,7 @@ def handle_github_url(cls, url, storage_options):
     register_implementation("github", GitHubPath, clobber=True)
 
 
+@pytest.mark.skipif("GITHUB_TOKEN" not in os.environ, reason="No GITHUB_TOKEN found")
 def test_migration_for_github_subclass(github_subclass_old_style):
 
     readme = UPath("github://fsspec:universal_pathlib@main/README.md").read_text()

From 1b29429c97b54a8d8197d1df21069cd1782cb9e6 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Tue, 6 Feb 2024 14:19:37 +0100
Subject: [PATCH 42/50] tests: make pathlib3.12 tests succeed

---
 upath/tests/pathlib/test_pathlib_312.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/upath/tests/pathlib/test_pathlib_312.py b/upath/tests/pathlib/test_pathlib_312.py
index 0b2f80a9..1a706e9f 100644
--- a/upath/tests/pathlib/test_pathlib_312.py
+++ b/upath/tests/pathlib/test_pathlib_312.py
@@ -78,7 +78,8 @@ def test_constructor_common(self):
         self.assertEqual(P(P('a'), 'b'), P('a/b'))
         self.assertEqual(P(P('a'), P('b')), P('a/b'))
         self.assertEqual(P(P('a'), P('b'), P('c')), P(FakePath("a/b/c")))
-        self.assertEqual(P(P('./a:b')), P('./a:b'))
+        if os.name != "nt":
+            self.assertEqual(P(P('./a:b')), P('./a:b'))
 
     def test_bytes(self):
         P = self.cls

From aac5fb8053647e4649452718c54b6c725d5c50b5 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Wed, 7 Feb 2024 01:04:00 +0100
Subject: [PATCH 43/50] upath: backwards compat support for _drv, _root, and
 _parts

---
 upath/core.py        | 73 ++++++++++++++++++++++++++++++++++++++++++++
 upath/tests/cases.py |  9 ++++++
 2 files changed, 82 insertions(+)

diff --git a/upath/core.py b/upath/core.py
index 6fe63df8..7458c0f0 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -54,6 +54,9 @@ class UPath(PathlibPathShim, Path):
         "_storage_options",
         "_fs_cached",
         *PathlibPathShim.__missing_py312_slots__,
+        "__drv",
+        "__root",
+        "__parts",
     )
     if TYPE_CHECKING:
         _protocol: str
@@ -348,6 +351,76 @@ def _from_parts(cls, parts, **kwargs):
         obj.__init__(*parts, **kwargs)
         return obj
 
+    @property
+    def _drv(self):
+        # direct access to ._drv should emit a warning,
+        # but there is no good way of doing this for now...
+        try:
+            return self.__drv
+        except AttributeError:
+            self._load_parts()
+            return self.__drv
+
+    @_drv.setter
+    def _drv(self, value):
+        self.__drv = value
+
+    @property
+    def drive(self):
+        try:
+            return self.__drv
+        except AttributeError:
+            self._load_parts()
+            return self.__drv
+
+    @property
+    def _root(self):
+        # direct access to ._root should emit a warning,
+        # but there is no good way of doing this for now...
+        try:
+            return self.__root
+        except AttributeError:
+            self._load_parts()
+            return self.__root
+
+    @_root.setter
+    def _root(self, value):
+        self.__root = value
+
+    @property
+    def root(self):
+        try:
+            return self.__root
+        except AttributeError:
+            self._load_parts()
+            return self.__root
+
+    @property
+    def _parts(self):
+        # UPath._parts is not used anymore, and not available
+        # in pathlib.Path for Python 3.12 and later.
+        # Direct access to ._parts should emit a deprecation warning,
+        # but there is no good way of doing this for now...
+        try:
+            return self.__parts
+        except AttributeError:
+            self._load_parts()
+            self.__parts = super().parts
+            return list(self.__parts)
+
+    @_parts.setter
+    def _parts(self, value):
+        self.__parts = value
+
+    @property
+    def parts(self):
+        try:
+            return self.__parts
+        except AttributeError:
+            self._load_parts()
+            self.__parts = super().parts
+            return self.__parts
+
     # === pathlib.PurePath ============================================
 
     def __reduce__(self):
diff --git a/upath/tests/cases.py b/upath/tests/cases.py
index 3955f325..bed42126 100644
--- a/upath/tests/cases.py
+++ b/upath/tests/cases.py
@@ -464,3 +464,12 @@ def test_read_with_fsspec(self):
         fs = filesystem(protocol, **storage_options)
         with fs.open(path) as f:
             assert f.read() == b"hello world"
+
+    def test_access_to_private_api(self):
+        # DO NOT access these private attributes in your code
+        p = UPath(str(self.path), **self.path.storage_options)
+        assert isinstance(p._drv, str)
+        p = UPath(str(self.path), **self.path.storage_options)
+        assert isinstance(p._root, str)
+        p = UPath(str(self.path), **self.path.storage_options)
+        assert isinstance(p._parts, (list, tuple))

From eca6e3281d53935979a1b050565f6fd59fa88aca Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Wed, 7 Feb 2024 01:05:12 +0100
Subject: [PATCH 44/50] upath: add comment regarding _url

---
 upath/core.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/upath/core.py b/upath/core.py
index 7458c0f0..034697f3 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -313,7 +313,10 @@ def _kwargs(self):
         return self.storage_options
 
     @property
-    def _url(self):  # todo: deprecate
+    def _url(self):
+        # TODO:
+        #   _url should be deprecated, but for now there is no good way of
+        #   accessing query parameters from urlpaths...
         return urlsplit(self.as_posix())
 
     def __getattr__(self, item):

From b29e836fc3256769d7f568fb9ab3cd4844592acd Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Wed, 7 Feb 2024 01:09:09 +0100
Subject: [PATCH 45/50] upath: backward compat support for _path

---
 upath/core.py | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/upath/core.py b/upath/core.py
index 034697f3..1442c24e 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -299,6 +299,18 @@ def fs(self) -> AbstractFileSystem:
     def path(self) -> str:
         return super().__str__()
 
+    @property
+    def _path(self):
+        warnings.warn(
+            "UPath._path is deprecated and should not be used."
+            " Please follow the universal_pathlib==0.2.0 migration guide at"
+            " https://github.com/fsspec/universal_pathlib for more"
+            " information.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return self.path
+
     @property
     def _kwargs(self):
         warnings.warn(

From c41b5c1075b9459d1510655c516db31dd8502a9a Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Wed, 7 Feb 2024 02:38:51 +0100
Subject: [PATCH 46/50] upath.core: warn when importing _UriFlavour or PT

---
 upath/core.py | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/upath/core.py b/upath/core.py
index 1442c24e..8c0941be 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -9,6 +9,7 @@
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Mapping
+from typing import TypeVar
 from urllib.parse import urlsplit
 
 from fsspec import AbstractFileSystem
@@ -25,6 +26,31 @@
 __all__ = ["UPath"]
 
 
+def __getattr__(name):
+    if name == "_UriFlavour":
+        warnings.warn(
+            "upath.core._UriFlavour should not be used anymore."
+            " Please follow the universal_pathlib==0.2.0 migration guide at"
+            " https://github.com/fsspec/universal_pathlib for more"
+            " information.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return FSSpecFlavour
+    elif name == "PT":
+        warnings.warn(
+            "upath.core.PT should not be used anymore."
+            " Please follow the universal_pathlib==0.2.0 migration guide at"
+            " https://github.com/fsspec/universal_pathlib for more"
+            " information.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return TypeVar("PT", bound="UPath")
+    else:
+        raise AttributeError(name)
+
+
 _FSSPEC_HAS_WORKING_GLOB = None
 
 

From 0e0de471e1b97b59bf17fa84bbab24d15cd25cff Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Wed, 7 Feb 2024 02:39:47 +0100
Subject: [PATCH 47/50] upath.core: improve comment regarding storage_options
 combination

---
 upath/core.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index 8c0941be..01fb2aa6 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -215,9 +215,10 @@ def __init__(
                 self._storage_options.get(key) != value
                 for key, value in arg.storage_options.items()
             ):
-                # raise ValueError(
-                #     "can't combine different UPath storage_options as parts"
-                # ) todo: revisit and define behaviour
+                # TODO:
+                #   Future versions of UPath could verify that storage_options
+                #   can be combined between UPath instances. Not sure if this
+                #   is really necessary though. A warning might be enough...
                 pass
 
         # fill ._raw_paths

From 00aad0a16929ecc208ceec913280a55167024a0b Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Wed, 7 Feb 2024 03:11:07 +0100
Subject: [PATCH 48/50] upath._compat: fix backwards compat for custom
 accessors

---
 upath/_compat.py | 5 +++--
 upath/core.py    | 6 +++++-
 2 files changed, 8 insertions(+), 3 deletions(-)

diff --git a/upath/_compat.py b/upath/_compat.py
index f9c655f3..d80a0f0b 100644
--- a/upath/_compat.py
+++ b/upath/_compat.py
@@ -424,8 +424,9 @@ def __init_subclass__(cls, **kwargs):
     @classmethod
     def from_path(cls, path: UPath) -> FSSpecAccessorShim:
         """internal accessor for backwards compatibility"""
-        obj = object.__new__(cls)
-        obj._fs = path.fs
+        url = path._url._replace(scheme=path.protocol)
+        obj = cls(url, **path.storage_options)
+        obj.__dict__["_fs"] = path.fs
         return obj
 
     def _format_path(self, path: UPath) -> str:
diff --git a/upath/core.py b/upath/core.py
index 01fb2aa6..c57da2ce 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -369,7 +369,11 @@ def __getattr__(self, item):
                 DeprecationWarning,
                 stacklevel=2,
             )
-            return FSSpecAccessorShim.from_path(self)
+            if hasattr(self, "_default_accessor"):
+                accessor_cls = self._default_accessor
+            else:
+                accessor_cls = FSSpecAccessorShim
+            return accessor_cls.from_path(self)
         else:
             raise AttributeError(item)
 

From ce2ddd397af0e259ccf58d1cf6072cdd73b359f3 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Wed, 7 Feb 2024 23:33:28 +0100
Subject: [PATCH 49/50] upath: provide missing deprecated classmethods on py312

---
 upath/_flavour.py | 104 +++++++++++++++++++++++++++-------------------
 upath/core.py     |  31 ++++++++++++++
 2 files changed, 93 insertions(+), 42 deletions(-)

diff --git a/upath/_flavour.py b/upath/_flavour.py
index 34c7a8de..3b64e0fb 100644
--- a/upath/_flavour.py
+++ b/upath/_flavour.py
@@ -4,7 +4,9 @@
 import os.path
 import posixpath
 import sys
+import warnings
 from functools import lru_cache
+from functools import wraps
 from typing import Any
 from typing import Callable
 from typing import Iterable
@@ -28,6 +30,23 @@
 ]
 
 
+def _deprecated(func):
+    if sys.version_info >= (3, 12):
+
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            warnings.warn(
+                f"{func.__name__} is deprecated on py3.12",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            return func(*args, **kwargs)
+
+        return wrapper
+    else:
+        return func
+
+
 class FSSpecFlavour:
     """fsspec flavour for universal_pathlib
 
@@ -181,48 +200,49 @@ def normcase(self, __path: PathOrStr) -> str:
         else:
             return os.path.normcase(__path)
 
-    if sys.version_info < (3, 12):
-
-        def parse_parts(self, parts):
-            parsed = []
-            sep = self.sep
-            drv = root = ""
-            it = reversed(parts)
-            for part in it:
-                if part:
-                    drv, root, rel = self.splitroot(part)
-                    if not root or root and rel:
-                        for x in reversed(rel.split(sep)):
-                            parsed.append(sys.intern(x))
-
-            if drv or root:
-                parsed.append(drv + root)
-            parsed.reverse()
-            return drv, root, parsed
-
-        def join_parsed_parts(self, drv, root, parts, drv2, root2, parts2):
-            """
-            Join the two paths represented by the respective
-            (drive, root, parts) tuples.  Return a new (drive, root, parts) tuple.
-            """
-            if root2:
-                if not drv2 and drv:
-                    return drv, root2, [drv + root2] + parts2[1:]
-            elif drv2:
-                if drv2 == drv or self.casefold(drv2) == self.casefold(drv):
-                    # Same drive => second path is relative to the first
-                    return drv, root, parts + parts2[1:]
-            else:
-                # Second path is non-anchored (common case)
-                return drv, root, parts + parts2
-            return drv2, root2, parts2
-
-        def casefold(self, s: str) -> str:
-            """Casefold the string s."""
-            if self.posixpath_only or os.name != "nt":
-                return s
-            else:
-                return s.lower()
+    @_deprecated
+    def parse_parts(self, parts):
+        parsed = []
+        sep = self.sep
+        drv = root = ""
+        it = reversed(parts)
+        for part in it:
+            if part:
+                drv, root, rel = self.splitroot(part)
+                if not root or root and rel:
+                    for x in reversed(rel.split(sep)):
+                        parsed.append(sys.intern(x))
+
+        if drv or root:
+            parsed.append(drv + root)
+        parsed.reverse()
+        return drv, root, parsed
+
+    @_deprecated
+    def join_parsed_parts(self, drv, root, parts, drv2, root2, parts2):
+        """
+        Join the two paths represented by the respective
+        (drive, root, parts) tuples.  Return a new (drive, root, parts) tuple.
+        """
+        if root2:
+            if not drv2 and drv:
+                return drv, root2, [drv + root2] + parts2[1:]
+        elif drv2:
+            if drv2 == drv or self.casefold(drv2) == self.casefold(drv):
+                # Same drive => second path is relative to the first
+                return drv, root, parts + parts2[1:]
+        else:
+            # Second path is non-anchored (common case)
+            return drv, root, parts + parts2
+        return drv2, root2, parts2
+
+    @_deprecated
+    def casefold(self, s: str) -> str:
+        """Casefold the string s."""
+        if self.posixpath_only or os.name != "nt":
+            return s
+        else:
+            return s.lower()
 
 
 @lru_cache
diff --git a/upath/core.py b/upath/core.py
index c57da2ce..e697f1ad 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -397,6 +397,37 @@ def _from_parts(cls, parts, **kwargs):
         obj.__init__(*parts, **kwargs)
         return obj
 
+    @classmethod
+    def _parse_args(cls, args):
+        warnings.warn(
+            "UPath._parse_args is deprecated and should not be used."
+            " Please follow the universal_pathlib==0.2.0 migration guide at"
+            " https://github.com/fsspec/universal_pathlib for more"
+            " information.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        pth = cls._flavour.join(*args)
+        return cls._parse_path(pth)
+
+    @classmethod
+    def _format_parsed_parts(cls, drv, root, tail, **kwargs):
+        if kwargs:
+            warnings.warn(
+                "UPath._format_parsed_parts should not be used with"
+                " additional kwargs. Please follow the"
+                " universal_pathlib==0.2.0 migration guide at"
+                " https://github.com/fsspec/universal_pathlib for more"
+                " information.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            if "url" in kwargs and tail[:1] == [f"{drv}{root}"]:
+                # This was called from code that expected py38-py311 behavior
+                # of _format_parsed_parts, which takes drv, root and parts
+                tail = tail[1:]
+        return super()._format_parsed_parts(drv, root, tail)
+
     @property
     def _drv(self):
         # direct access to ._drv should emit a warning,

From 31b9877968b9c21cf860da265a51d3f1f2d30d24 Mon Sep 17 00:00:00 2001
From: Andreas Poehlmann <ap--@users.noreply.github.com>
Date: Thu, 8 Feb 2024 20:21:47 +0100
Subject: [PATCH 50/50] upath: provide storage_options customization
 classmethod

---
 upath/core.py | 130 +++++++++++++++++++++++++-------------------------
 1 file changed, 65 insertions(+), 65 deletions(-)

diff --git a/upath/core.py b/upath/core.py
index e697f1ad..4b41eda6 100644
--- a/upath/core.py
+++ b/upath/core.py
@@ -92,6 +92,8 @@ class UPath(PathlibPathShim, Path):
     _protocol_dispatch: bool | None = None
     _flavour = FSSpecFlavour()
 
+    # === upath.UPath constructor =====================================
+
     def __new__(
         cls, *args, protocol: str | None = None, **storage_options: Any
     ) -> UPath:
@@ -113,14 +115,16 @@ def __new__(
             )
             protocol = storage_options.pop("scheme")
 
-        # determine which UPath subclass to dispatch to
+        # determine the protocol
         pth_protocol = get_upath_protocol(
             part0, protocol=protocol, storage_options=storage_options
         )
-        upath_cls = get_upath_class(protocol=pth_protocol)
-        if upath_cls is None:
-            raise ValueError(f"Unsupported filesystem: {pth_protocol!r}")
-        if cls._protocol_dispatch is not None and not cls._protocol_dispatch:
+        # determine which UPath subclass to dispatch to
+        if cls._protocol_dispatch or cls._protocol_dispatch is None:
+            upath_cls = get_upath_class(protocol=pth_protocol)
+            if upath_cls is None:
+                raise ValueError(f"Unsupported filesystem: {pth_protocol!r}")
+        else:
             # user subclasses can request to disable protocol dispatch
             # by setting MyUPathSubclass._protocol_dispatch to `False`.
             # This will effectively ignore the registered UPath
@@ -194,11 +198,9 @@ def __init__(
             if isinstance(args0, UPath):
                 self._storage_options = {**args0.storage_options, **storage_options}
             else:
-                fs_cls: type[AbstractFileSystem] = get_filesystem_class(
-                    protocol or self._protocol
+                self._storage_options = type(self)._parse_storage_options(
+                    str(args0), protocol, storage_options
                 )
-                pth_storage_options = fs_cls._get_kwargs_from_urls(str(args0))
-                self._storage_options = {**pth_storage_options, **storage_options}
         else:
             self._storage_options = storage_options.copy()
 
@@ -226,7 +228,31 @@ def __init__(
             return
         super().__init__(*args)
 
-    # === upath.UPath only ============================================
+    # === upath.UPath PUBLIC ADDITIONAL API ===========================
+
+    @property
+    def protocol(self) -> str:
+        return self._protocol
+
+    @property
+    def storage_options(self) -> Mapping[str, Any]:
+        return MappingProxyType(self._storage_options)
+
+    @property
+    def fs(self) -> AbstractFileSystem:
+        try:
+            return self._fs_cached
+        except AttributeError:
+            fs = self._fs_cached = self._fs_factory(
+                str(self), self.protocol, self.storage_options
+            )
+            return fs
+
+    @property
+    def path(self) -> str:
+        return super().__str__()
+
+    # === upath.UPath CUSTOMIZABLE API ================================
 
     @classmethod
     def _transform_init_args(
@@ -238,13 +264,26 @@ def _transform_init_args(
         """allow customization of init args in subclasses"""
         return args, protocol, storage_options
 
-    @property
-    def protocol(self) -> str:
-        return self._protocol
+    @classmethod
+    def _parse_storage_options(
+        cls, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
+    ) -> dict[str, Any]:
+        """Parse storage_options from the urlpath"""
+        fs_cls: type[AbstractFileSystem] = get_filesystem_class(protocol)
+        pth_storage_options = fs_cls._get_kwargs_from_urls(urlpath)
+        return {**pth_storage_options, **storage_options}
 
-    @property
-    def storage_options(self) -> Mapping[str, Any]:
-        return MappingProxyType(self._storage_options)
+    @classmethod
+    def _fs_factory(
+        cls, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
+    ) -> AbstractFileSystem:
+        """Instantiate the filesystem_spec filesystem class"""
+        fs_cls = get_filesystem_class(protocol)
+        so_dct = fs_cls._get_kwargs_from_urls(urlpath)
+        so_dct.update(storage_options)
+        return fs_cls(**storage_options)
+
+    # === upath.UPath COMPATIBILITY API ===============================
 
     def __init_subclass__(cls, **kwargs):
         """provide a clean migration path for custom user subclasses"""
@@ -300,31 +339,17 @@ def _fs_factory(
                 inst = cls_._default_accessor(url, **storage_options)
                 return inst._fs
 
-            cls._fs_factory = classmethod(_fs_factory)
-
-    @classmethod
-    def _fs_factory(
-        cls, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
-    ) -> AbstractFileSystem:
-        """Instantiate the filesystem_spec filesystem class"""
-        fs_cls = get_filesystem_class(protocol)
-        so_dct = fs_cls._get_kwargs_from_urls(urlpath)
-        so_dct.update(storage_options)
-        return fs_cls(**storage_options)
-
-    @property
-    def fs(self) -> AbstractFileSystem:
-        try:
-            return self._fs_cached
-        except AttributeError:
-            fs = self._fs_cached = self._fs_factory(
-                str(self), self.protocol, self.storage_options
-            )
-            return fs
+            def _parse_storage_options(
+                cls_, urlpath: str, protocol: str, storage_options: Mapping[str, Any]
+            ) -> dict[str, Any]:
+                url = urlsplit(urlpath)
+                if protocol:
+                    url = url._replace(scheme=protocol)
+                inst = cls_._default_accessor(url, **storage_options)
+                return inst._fs.storage_options
 
-    @property
-    def path(self) -> str:
-        return super().__str__()
+            cls._fs_factory = classmethod(_fs_factory)
+            cls._parse_storage_options = classmethod(_parse_storage_options)
 
     @property
     def _path(self):
@@ -442,14 +467,6 @@ def _drv(self):
     def _drv(self, value):
         self.__drv = value
 
-    @property
-    def drive(self):
-        try:
-            return self.__drv
-        except AttributeError:
-            self._load_parts()
-            return self.__drv
-
     @property
     def _root(self):
         # direct access to ._root should emit a warning,
@@ -464,14 +481,6 @@ def _root(self):
     def _root(self, value):
         self.__root = value
 
-    @property
-    def root(self):
-        try:
-            return self.__root
-        except AttributeError:
-            self._load_parts()
-            return self.__root
-
     @property
     def _parts(self):
         # UPath._parts is not used anymore, and not available
@@ -489,15 +498,6 @@ def _parts(self):
     def _parts(self, value):
         self.__parts = value
 
-    @property
-    def parts(self):
-        try:
-            return self.__parts
-        except AttributeError:
-            self._load_parts()
-            self.__parts = super().parts
-            return self.__parts
-
     # === pathlib.PurePath ============================================
 
     def __reduce__(self):
