Repository: databrickslabs/blueprint

Issue:
Support for python 3.12
Currently python 3.12 is mostly supported, although it turns out that there are some issues especially with `WorkspacePath`. (These issues are visible via unit tests, which currently fail.)

After some internal discussion on whether this needs to be supported, we decided that it does need to be.

Discussion:
@asnare: Closed via #122 

Solution patch:
diff --git a/pyproject.toml b/pyproject.toml
index 5aeb537..2e299ed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,7 @@ classifiers = [
     "Programming Language :: Python",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
     "Programming Language :: Python :: Implementation :: CPython",
 ]
 dependencies = ["databricks-sdk>=0.16.0"]
@@ -64,9 +65,9 @@ fmt         = ["isort .",
                "ruff check . --fix",
                "mypy .",
                "pylint --output-format=colorized -j 0 src"]
-verify      = ["black --check .",
-               "isort . --check-only",
-               "ruff .",
+verify      = ["isort . --check-only",
+               "ruff format --check --diff",
+               "ruff check .",
                "mypy .",
                "pylint --output-format=colorized -j 0 src"]
 
diff --git a/src/databricks/labs/blueprint/paths.py b/src/databricks/labs/blueprint/paths.py
index 8903e66..875cc93 100644
--- a/src/databricks/labs/blueprint/paths.py
+++ b/src/databricks/labs/blueprint/paths.py
@@ -1,15 +1,17 @@
+from __future__ import annotations
+
 import abc
 import fnmatch
 import locale
 import logging
 import os
-import pathlib
 import posixpath
 import re
-import sys
-from functools import cached_property
+from abc import abstractmethod
+from collections.abc import Iterable, Sequence
 from io import BytesIO, StringIO
-from pathlib import Path
+from pathlib import Path, PurePath
+from typing import NoReturn, TypeVar
 from urllib.parse import quote_from_bytes as urlquote_from_bytes
 
 from databricks.sdk import WorkspaceClient
@@ -25,90 +27,6 @@ from databricks.sdk.service.workspace import (
 logger = logging.getLogger(__name__)
 
 
-class _DatabricksFlavour:
-    # adapted from pathlib._Flavour, where we ignore support for drives, as we
-    # don't have that concept in Databricks. We also ignore support for Windows
-    # paths, as we only support POSIX paths in Databricks.
-
-    sep = "/"
-    altsep = ""
-    has_drv = False
-    pathmod = posixpath
-    is_supported = True
-
-    def __init__(self, ws: WorkspaceClient):
-        self.join = self.sep.join
-        self._ws = ws
-
-    def parse_parts(self, parts: list[str]) -> tuple[str, str, list[str]]:
-        # adapted from pathlib._Flavour.parse_parts,
-        # where we ignore support for drives, as we
-        # don't have that concept in Databricks
-        parsed = []
-        drv = root = ""
-        for part in reversed(parts):
-            if not part:
-                continue
-            drv, root, rel = self.splitroot(part)
-            if self.sep not in rel:
-                if rel and rel != ".":
-                    parsed.append(sys.intern(rel))
-                continue
-            for part_ in reversed(rel.split(self.sep)):
-                if part_ and part_ != ".":
-                    parsed.append(sys.intern(part_))
-        if drv or root:
-            parsed.append(drv + root)
-        parsed.reverse()
-        return drv, root, parsed
-
-    @staticmethod
-    def join_parsed_parts(
-        drv: str,
-        root: str,
-        parts: list[str],
-        _,
-        root2: str,
-        parts2: list[str],
-    ) -> tuple[str, str, list[str]]:
-        # adapted from pathlib.PurePosixPath, where we ignore support for drives,
-        # as we don't have that concept in Databricks
-        if root2:
-            return drv, root2, [drv + root2] + parts2[1:]
-        return drv, root, parts + parts2
-
-    @staticmethod
-    def splitroot(part, sep=sep) -> tuple[str, str, str]:
-        if part and part[0] == sep:
-            stripped_part = part.lstrip(sep)
-            if len(part) - len(stripped_part) == 2:
-                return "", sep * 2, stripped_part
-            return "", sep, stripped_part
-        return "", "", part
-
-    @staticmethod
-    def casefold(value: str) -> str:
-        return value
-
-    @staticmethod
-    def casefold_parts(parts: list[str]) -> list[str]:
-        return parts
-
-    @staticmethod
-    def compile_pattern(pattern: str):
-        return re.compile(fnmatch.translate(pattern)).fullmatch
-
-    @staticmethod
-    def is_reserved(_) -> bool:
-        return False
-
-    def make_uri(self, path) -> str:
-        return self._ws.config.host + "#workspace" + urlquote_from_bytes(bytes(path))
-
-    def __repr__(self):
-        return f"<{self.__class__.__name__} for {self._ws}>"
-
-
 def _na(fn: str):
     def _inner(*_, **__):
         __tracebackhide__ = True  # pylint: disable=unused-variable
@@ -117,75 +35,6 @@ def _na(fn: str):
     return _inner
 
 
-class _ScandirItem:
-    def __init__(self, object_info):
-        self._object_info = object_info
-
-    def __fspath__(self):
-        return self._object_info.path
-
-    def is_dir(self, follow_symlinks=False):  # pylint: disable=unused-argument
-        # follow_symlinks is for compatibility with Python 3.11
-        return self._object_info.object_type == ObjectType.DIRECTORY
-
-    def is_file(self, follow_symlinks=False):  # pylint: disable=unused-argument
-        # follow_symlinks is for compatibility with Python 3.11
-        # TODO: check if we want to show notebooks as files
-        return self._object_info.object_type == ObjectType.FILE
-
-    def is_symlink(self):
-        return False
-
-    @property
-    def name(self):
-        return os.path.basename(self._object_info.path)
-
-
-class _ScandirIterator:
-    def __init__(self, objects):
-        self._it = objects
-
-    def __iter__(self):
-        for object_info in self._it:
-            yield _ScandirItem(object_info)
-
-    def __enter__(self):
-        return self
-
-    def __exit__(self, *args):
-        pass
-
-
-class _DatabricksAccessor:
-    chmod = _na("accessor.chmod")
-    getcwd = _na("accessor.getcwd")
-    group = _na("accessor.group")
-    link = _na("accessor.link")
-    mkdir = _na("accessor.mkdir")
-    owner = _na("accessor.owner")
-    readlink = _na("accessor.readlink")
-    realpath = _na("accessor.realpath")
-    rename = _na("accessor.rename")
-    replace = _na("accessor.replace")
-    rmdir = _na("accessor.rmdir")
-    stat = _na("accessor.stat")
-    symlink = _na("accessor.symlink")
-    unlink = _na("accessor.unlink")
-
-    def __init__(self, ws: WorkspaceClient):
-        self._ws = ws
-
-    def __repr__(self):
-        return f"<{self.__class__.__name__} for {self._ws}>"
-
-    def scandir(self, path):
-        objects = self._ws.workspace.list(path)
-        return _ScandirIterator(objects)
-
-    def listdir(self, path):
-        return [item.name for item in self.scandir(path)]
-
-
 class _UploadIO(abc.ABC):
     def __init__(self, ws: WorkspaceClient, path: str):
         self._ws = ws
@@ -212,14 +61,55 @@ class _TextUploadIO(_UploadIO, StringIO):  # type: ignore
         StringIO.__init__(self)
 
 
-class WorkspacePath(Path):
+class WorkspacePath(Path):  # pylint: disable=too-many-public-methods
     """Experimental implementation of pathlib.Path for Databricks Workspace."""
 
+    # Implementation notes:
+    #  - The builtin Path classes are not designed for extension, which in turn makes everything a little cumbersome.
+    #  - The internals of the builtin pathlib have changed dramatically across supported Python versions (3.10-3.12 at
+    #    the time of writing) so relying on those details is brittle. (Python 3.13 also includes a significant
+    #    refactoring.)
+    #  - Until 3.11 the implementation was decomposed and delegated to two internal interfaces:
+    #     1. Flavour (scope=class) which encapsulates the path style and manipulation.
+    #     2. Accessor (scope=instance) to which I/O-related calls are delegated.
+    #    These interfaces are internal/protected.
+    #  - Since 3.12 the implementation of these interfaces have been removed:
+    #     1. Flavour has been replaced with posixpath and ntpath (normally imported as os.path). Still class-scoped.
+    #     2. Accessor has been replaced with inline implementations based directly on the 'os' module.
+    #
+    # This implementation for Workspace paths does the following:
+    #     1. Flavour is basically posix-style, with the caveat that we don't bother with the special //-prefix handling.
+    #     2. The Accessor is delegated to existing routines available via the workspace client.
+    #     3. Python 3.12 introduces some new API elements. Because these are source-compatible with earlier versions
+    #        these are forward-ported and implemented.
+    #
+    __slots__ = (  # pylint: disable=redefined-slots-in-subclass
+        # For us this is always the empty string. Consistent with the superclass attribute for Python 3.10-3.13b.
+        "_drv",
+        # The (normalized) root property for the path. Consistent with the superclass attribute for Python 3.10-3.13b.
+        "_root",
+        # The (normalized) path components (relative to the root) for the path.
+        #  - For python <=3.11 this supersedes _parts
+        #  - For python 3.12+ this supersedes _raw_paths
+        "_path_parts",
+        # The cached str() value of the instance. Consistent with the superclass attribute for Python 3.10-3.13b.
+        "_str",
+        # The cached hash() value for the instance. Consistent with the superclass attribute for Python 3.10-3.13b.
+        "_hash",
+        # The workspace client that we use to perform I/O operations on the path.
+        "_ws",
+        # The cached _object_info value for the instance.
+        "_cached_object_info",
+    )
+    _cached_object_info: ObjectInfo
+
     _SUFFIXES = {".py": Language.PYTHON, ".sql": Language.SQL, ".scala": Language.SCALA, ".R": Language.R}
 
-    _ws: WorkspaceClient
-    _flavour: _DatabricksFlavour
-    _accessor: _DatabricksAccessor
+    # Path semantics are posix-like.
+    parser = posixpath
+
+    # Compatibility attribute, for when superclass implementations get invoked on python <= 3.11.
+    _flavour = object()
 
     cwd = _na("cwd")
     stat = _na("stat")
@@ -235,66 +125,325 @@ class WorkspacePath(Path):
     link_to = _na("link_to")
     samefile = _na("samefile")
 
-    def __new__(cls, ws: WorkspaceClient, path: str | Path):
-        this = object.__new__(cls)
-        # pathlib does a lot of clever performance tricks, and it's not designed to be subclassed,
-        # so we need to set the attributes directly, bypassing the most of a common sense.
-        this._flavour = _DatabricksFlavour(ws)
-        drv, root, parts = this._parse_args([path])
-        return this.__from_raw_parts(this, ws, this._flavour, drv, root, parts)
+    def __new__(cls, *args, **kwargs) -> WorkspacePath:
+        # Force all initialisation to go via __init__() irrespective of the (Python-specific) base version.
+        return object.__new__(cls)
+
+    def __init__(self, ws: WorkspaceClient, *args) -> None:  # pylint: disable=super-init-not-called,useless-suppression
+        # We deliberately do _not_ call the super initializer because we're taking over complete responsibility for the
+        # implementation of the public API.
+
+        # Convert the arguments into string-based path segments, irrespective of their type.
+        raw_paths = self._to_raw_paths(*args)
+
+        # Normalise the paths that we have.
+        root, path_parts = self._parse_and_normalize(raw_paths)
+
+        self._drv = ""
+        self._root = root
+        self._path_parts = path_parts
+        self._ws = ws
+
+    @classmethod
+    def _from_object_info(cls, ws: WorkspaceClient, object_info: ObjectInfo):
+        """Special (internal-only) constructor that creates an instance based on ObjectInfo."""
+        if not object_info.path:
+            msg = f"Cannot initialise within object path: {object_info}"
+            raise ValueError(msg)
+        path = cls(ws, object_info.path)
+        path._cached_object_info = object_info
+        return path
 
     @staticmethod
-    def __from_raw_parts(this, ws: WorkspaceClient, flavour: _DatabricksFlavour, drv, root, parts) -> "WorkspacePath":
-        # pylint: disable=protected-access
-        this._accessor = _DatabricksAccessor(ws)
-        this._flavour = flavour
-        this._drv = drv
-        this._root = root
-        this._parts = parts
-        this._ws = ws
-        return this
-
-    def _make_child_relpath(self, part):
-        # used in dir walking
-        path = self._flavour.join(self._parts + [part])
-        # self._flavour.join duplicates leading '/' (possibly a python bug)
-        # but we can't override join in _DatabricksFlavour because it's built-in
-        # and if we remove the leading '/' part then we don't get any
-        # so let's just do a slow but safe sanity check afterward
-        if os.sep == path[0] == path[1]:
-            path = path[1:]
-        return WorkspacePath(self._ws, path)
-
-    def _parse_args(self, args):  # pylint: disable=arguments-differ
-        # instance method adapted from pathlib.Path
-        parts = []
-        for a in args:
-            if isinstance(a, pathlib.PurePath):
-                parts += a._parts  # pylint: disable=protected-access
-                continue
-            parts.append(str(a))
-        return self._flavour.parse_parts(parts)
-
-    def _format_parsed_parts(self, drv, root, parts):  # pylint: disable=arguments-differ
-        # instance method adapted from pathlib.Path
-        if drv or root:
-            return drv + root + self._flavour.join(parts[1:])
-        return self._flavour.join(parts)
-
-    def _from_parsed_parts(self, drv, root, parts):  # pylint: disable=arguments-differ
-        # instance method adapted from pathlib.Path
-        this = object.__new__(self.__class__)
-        return self.__from_raw_parts(this, self._ws, self._flavour, drv, root, parts)
-
-    def _from_parts(self, args):  # pylint: disable=arguments-differ
-        # instance method adapted from pathlib.Path
-        drv, root, parts = self._parse_args(args)
-        return self._from_parsed_parts(drv, root, parts)
-
-    def relative_to(self, *other) -> pathlib.PurePath:  # type: ignore
-        """Databricks Workspace works only with absolute paths, so we make sure to
-        return pathlib.Path instead of WorkspacePath to avoid confusion."""
-        return pathlib.PurePath(super().relative_to(*other))
+    def _to_raw_paths(*args) -> list[str]:
+        raw_paths: list[str] = []
+        for arg in args:
+            if isinstance(arg, PurePath):
+                raw_paths.extend(arg.parts)
+            else:
+                try:
+                    path = os.fspath(arg)
+                except TypeError:
+                    path = arg
+                if not isinstance(path, str):
+                    msg = (
+                        f"argument should be a str or an os.PathLib object where __fspath__ returns a str, "
+                        f"not {type(path).__name__!r}"
+                    )
+                    raise TypeError(msg)
+                raw_paths.append(path)
+        return raw_paths
+
+    @classmethod
+    def _parse_and_normalize(cls, parts: list[str]) -> tuple[str, tuple[str, ...]]:
+        """Parse and normalize a list of path components.
+
+        Args:
+            parts: a list of path components to parse and normalize.
+        Returns:
+            A tuple containing:
+              - The normalized drive (always '')
+              - The normalized root for this path, or '' if there isn't any.
+              - The normalized path components, if any, (relative) to the root.
+        """
+        match parts:
+            case []:
+                path = ""
+            case [part]:
+                path = part
+            case [*parts]:
+                path = cls.parser.join(*parts)
+        if not path:
+            return "", ()
+        root, rel = cls._splitroot(path, sep=cls.parser.sep)
+        # No need to split drv because we don't support it.
+        parsed = tuple(str(x) for x in rel.split(cls.parser.sep) if x and x != ".")
+        return root, parsed
+
+    @classmethod
+    def _splitroot(cls, part: str, sep: str) -> tuple[str, str]:
+        # Based on the upstream implementation, with the '//'-specific bit elided because we don't need to
+        # bother with Posix semantics.
+        if part and part[0] == sep:
+            return sep, part.lstrip(sep)
+        return "", part
+
+    def __reduce__(self) -> NoReturn:
+        # Cannot support pickling because we can't pickle the workspace client.
+        msg = "Pickling Workspace paths is not supported."
+        raise NotImplementedError(msg)
+
+    def __fspath__(self):
+        # Cannot support this: Workspace objects aren't accessible via the filesystem.
+        #
+        # This method is part of the os.PathLike protocol. Functions which accept a PathLike argument use os.fsname()
+        # to convert (via this method) the object into a file system path that can be used with the low-level os.*
+        # methods.
+        #
+        # Relevant online documentation:
+        #  - PEP 519 (https://peps.python.org/pep-0519/)
+        #  - os.fspath (https://docs.python.org/3/library/os.html#os.fspath)
+        # TODO: Allow this to work when within an appropriate Databricks Runtime that mounts Workspace paths via FUSE.
+        msg = f"Workspace paths are not path-like: {self}"
+        raise NotImplementedError(msg)
+
+    def as_posix(self):
+        return str(self)
+
+    def __str__(self):
+        try:
+            return self._str
+        except AttributeError:
+            self._str = (self._root + self.parser.sep.join(self._path_parts)) or "."
+            return self._str
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({str(self)!r})"
+
+    def as_uri(self) -> str:
+        return self._ws.config.host + "#workspace" + urlquote_from_bytes(bytes(self))
+
+    def __eq__(self, other):
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return str(self) == str(other)
+
+    def __hash__(self):
+        try:
+            return self._hash
+        except AttributeError:
+            self._hash = hash(str(self))
+            return self._hash
+
+    def _parts(self) -> tuple[str, ...]:
+        """Return a tuple that has the same natural ordering as paths of this type."""
+        return self._root, *self._path_parts
+
+    @property
+    def _cparts(self):
+        # Compatibility property (python <= 3.11), accessed via reverse equality comparison. This can't be avoided.
+        return self._parts()
+
+    @property
+    def _str_normcase(self):
+        # Compatibility property (python 3.12+), accessed via equality comparison. This can't be avoided.
+        return str(self)
+
+    @classmethod
+    def _from_parts(cls, *args) -> NoReturn:
+        # Compatibility method (python <= 3.11), accessed via reverse /-style building. This can't be avoided.
+        # See __rtruediv__ for more information.
+        raise TypeError("trigger NotImplemented")
+
+    @property
+    def _raw_paths(self) -> NoReturn:
+        # Compatibility method (python 3.12+), accessed via reverse /-style building. This can't be avoided.
+        # See __rtruediv__ for more information.
+        raise TypeError("trigger NotImplemented")
+
+    def __lt__(self, other):
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self._path_parts < other._path_parts
+
+    def __le__(self, other):
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self._path_parts <= other._path_parts
+
+    def __gt__(self, other):
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self._path_parts > other._path_parts
+
+    def __ge__(self, other):
+        if not isinstance(other, type(self)):
+            return NotImplemented
+        return self._path_parts >= other._path_parts
+
+    def with_segments(self, *pathsegments):
+        return type(self)(self._ws, *pathsegments)
+
+    @property
+    def drive(self) -> str:
+        return self._drv
+
+    @property
+    def root(self):
+        return self._root
+
+    @property
+    def anchor(self):
+        return self.drive + self.root
+
+    @property
+    def name(self):
+        path_parts = self._path_parts
+        return path_parts[-1] if path_parts else ""
+
+    @property
+    def parts(self):
+        if self.drive or self.root:
+            return self.drive + self.root, *self._path_parts
+        return self._path_parts
+
+    def with_name(self, name):
+        parser = self.parser
+        if not name or parser.sep in name or name == ".":
+            msg = f"Invalid name: {name!r}"
+            raise ValueError(msg)
+        path_parts = list(self._path_parts)
+        if not path_parts:
+            raise ValueError(f"{self!r} has an empty name")
+        path_parts[-1] = name
+        return type(self)(self._ws, self.anchor, *path_parts)
+
+    def with_suffix(self, suffix):
+        stem = self.stem
+        if not stem:
+            msg = f"{self!r} has an empty name"
+            raise ValueError(msg)
+        if suffix and not suffix.startswith("."):
+            msg = f"{self!r} invalid suffix: {suffix}"
+            raise ValueError(msg)
+        return self.with_name(stem + suffix)
+
+    def relative_to(self, other, *more_other, walk_up=False):  # pylint: disable=arguments-differ
+        other = self.with_segments(other, *more_other)
+        if self.anchor != other.anchor:
+            msg = f"{str(self)!r} and {str(other)!r} have different anchors"
+            raise ValueError(msg)
+        path_parts0 = self._path_parts
+        path_parts1 = other._path_parts  # pylint: disable=protected-access
+        # Find the length of the common prefix.
+        i = 0
+        while i < len(path_parts0) and i < len(path_parts1) and path_parts0[i] == path_parts1[i]:
+            i += 1
+        relative_parts = path_parts0[i:]
+        # Handle walking up.
+        if i < len(path_parts1):
+            if not walk_up:
+                msg = f"{str(self)!r} is not in the subpath of {str(other)!r}"
+                raise ValueError(msg)
+            if ".." in path_parts1[i:]:
+                raise ValueError(f"'..' segment in {str(other)!r} cannot be walked")
+            walkup_parts = [".."] * (len(path_parts1) - i)
+            relative_parts = (*walkup_parts, *relative_parts)
+        return self.with_segments("", *relative_parts)
+
+    def is_relative_to(self, other, *more_other):  # pylint: disable=arguments-differ
+        other = self.with_segments(other, *more_other)
+        if self.anchor != other.anchor:
+            return False
+        path_parts0 = self._path_parts
+        path_parts1 = other._path_parts  # pylint: disable=protected-access
+        return path_parts0[: len(path_parts1)] == path_parts1
+
+    @property
+    def parent(self):
+        rel_path = self._path_parts
+        return self.with_segments(self.anchor, *rel_path[:-1]) if rel_path else self
+
+    @property
+    def parents(self):
+        parents = []
+        path = self
+        parent = path.parent
+        while path != parent:
+            parents.append(parent)
+            path = parent
+            parent = path.parent
+        return tuple(parents)
+
+    def is_absolute(self):
+        return bool(self.anchor)
+
+    def is_reserved(self):
+        return False
+
+    def joinpath(self, *pathsegments):
+        return self.with_segments(self, *pathsegments)
+
+    def __truediv__(self, other):
+        try:
+            return self.with_segments(*self._parts(), other)
+        except TypeError:
+            return NotImplemented
+
+    def __rtruediv__(self, other):
+        # Note: this is only invoked if __truediv__ has already returned NotImplemented.
+        # For the case of Path / WorkspacePath this means the underlying __truediv__ is invoked.
+        # The base-class implementations all access internals but yield NotImplemented if TypeError is raised. As
+        # such we stub those internals (_from_parts and _raw_path) to trigger the NotImplemented path and ensure that
+        # control ends up here.
+        try:
+            if isinstance(other, PurePath):
+                return type(other)(other, *self._parts())
+            return self.with_segments(other, *self._parts())
+        except TypeError:
+            return NotImplemented
+
+    def match(self, path_pattern, *, case_sensitive=None):
+        # Convert the pattern to a fake path (with globs) to help with matching parts.
+        if not isinstance(path_pattern, PurePath):
+            path_pattern = self.with_segments(path_pattern)
+        # Default to false if not specified.
+        if case_sensitive is None:
+            case_sensitive = True
+
+        pattern_parts = path_pattern.parts
+        if not pattern_parts:
+            raise ValueError("empty pattern")
+        # Short-circuit on situations where a match is logically impossible.
+        path_parts = self.parts
+        if len(path_parts) < len(pattern_parts) or len(path_parts) > len(pattern_parts) and path_pattern.anchor:
+            return False
+        # Check each part, starting from the end.
+        for path_part, pattern_part in zip(reversed(path_parts), reversed(pattern_parts)):
+            pattern = _PatternSelector.compile_pattern(pattern_part, case_sensitive=case_sensitive)
+            if not pattern.match(path_part):
+                return False
+        return True
 
     def as_fuse(self):
         """Return FUSE-mounted path in Databricks Runtime."""
@@ -378,16 +527,15 @@ class WorkspacePath(Path):
                 return ""
         return ""
 
-    def __lt__(self, other: pathlib.PurePath):
-        if not isinstance(other, pathlib.PurePath):
-            return NotImplemented
-        return self.as_posix() < other.as_posix()
-
-    @cached_property
+    @property
     def _object_info(self) -> ObjectInfo:
         # this method is cached because it is used in multiple is_* methods.
         # DO NOT use this method in methods, where fresh result is required.
-        return self._ws.workspace.get_status(self.as_posix())
+        try:
+            return self._cached_object_info
+        except AttributeError:
+            self._cached_object_info = self._ws.workspace.get_status(self.as_posix())
+            return self._object_info
 
     def _return_false(self) -> bool:
         return False
@@ -418,24 +566,19 @@ class WorkspacePath(Path):
         except DatabricksError:
             return False
 
-    def _scandir(self):
-        # Python 3.10: Accesses _accessor.scandir() directly.
-        # Python 3.11: Instead invokes this (which normally dispatches to os.scandir())
-        return self._accessor.scandir(self)
-
     def expanduser(self):
         # Expand ~ (but NOT ~user) constructs.
-        if not (self._drv or self._root) and self._parts and self._parts[0][:1] == "~":
-            if self._parts[0] == "~":
+        if not (self._drv or self._root) and self._path_parts and self._path_parts[0][:1] == "~":
+            if self._path_parts[0] == "~":
                 user_name = self._ws.current_user.me().user_name
             else:
-                other_user = self._parts[0][1:]
+                other_user = self._path_parts[0][1:]
                 msg = f"Cannot determine home directory for: {other_user}"
                 raise RuntimeError(msg)
             if user_name is None:
                 raise RuntimeError("Could not determine home directory.")
             homedir = f"/Users/{user_name}"
-            return self._from_parts([homedir, *self._parts[1:]])
+            return self.with_segments(homedir, *self._path_parts[1:])
         return self
 
     def is_notebook(self):
@@ -445,8 +588,162 @@ class WorkspacePath(Path):
         except DatabricksError:
             return False
 
-    def __eq__(self, other):
-        return isinstance(other, Path) and self.as_posix() == other.as_posix()
-
-    def __hash__(self):
-        return Path.__hash__(self)
+    def iterdir(self):
+        for child in self._ws.workspace.list(self.as_posix()):
+            yield self._from_object_info(self._ws, child)
+
+    def _prepare_pattern(self, pattern) -> Sequence[str]:
+        if not pattern:
+            raise ValueError("Glob pattern must not be empty.")
+        parsed_pattern = self.with_segments(pattern)
+        if parsed_pattern.anchor:
+            msg = f"Non-relative patterns are unsupported: {pattern}"
+            raise NotImplementedError(msg)
+        pattern_parts = parsed_pattern._path_parts  # pylint: disable=protected-access
+        if ".." in pattern_parts:
+            msg = f"Parent traversal is not supported: {pattern}"
+            raise ValueError(msg)
+        if pattern[-1] == self.parser.sep:
+            pattern_parts = (*pattern_parts, "")
+        return pattern_parts
+
+    def glob(self, pattern, *, case_sensitive=None):
+        pattern_parts = self._prepare_pattern(pattern)
+        selector = _Selector.parse(pattern_parts, case_sensitive=case_sensitive if case_sensitive is not None else True)
+        yield from selector(self)
+
+    def rglob(self, pattern, *, case_sensitive=None):
+        pattern_parts = ("**", *self._prepare_pattern(pattern))
+        selector = _Selector.parse(pattern_parts, case_sensitive=case_sensitive if case_sensitive is not None else True)
+        yield from selector(self)
+
+
+T = TypeVar("T", bound="Path")
+
+
+class _Selector(abc.ABC):
+    @classmethod
+    def parse(cls, pattern_parts: Sequence[str], *, case_sensitive: bool) -> _Selector:
+        # The pattern language is:
+        #  - '**' matches any number (including zero) of file or directory segments. Must be the entire segment.
+        #  - '*' match any number of characters within a single segment.
+        #  - '?' match a single character within a segment.
+        #  - '[seq]' match a single character against the class (within a segment).
+        #  - '[!seq]' negative match for a single character against the class (within a segment).
+        #  - A trailing '/' (which presents here as a trailing empty segment) matches only directories.
+        # There is no explicit escaping mechanism; literal matches against special characters above are possible as
+        # character classes, for example: [*]
+        #
+        # Some sharp edges:
+        #  - Multiple '**' segments are allowed.
+        #  - Normally the '..' segment is allowed. (This can be used to match against siblings, for
+        #    example: /home/bob/../jane/) However WorspacePath (and DBFS) do not support '..' traversal in paths.
+        #  - Normally '.' is allowed, but eliminated before we reach this method.
+        match pattern_parts:
+            case ["**", *tail]:
+                return _RecursivePatternSelector(tail, case_sensitive=case_sensitive)
+            case [head, *tail] if case_sensitive and not _PatternSelector.needs_pattern(head):
+                return _LiteralSelector(head, tail, case_sensitive=case_sensitive)
+            case [head, *tail]:
+                if "**" in head:
+                    raise ValueError("Invalid pattern: '**' can only be a complete path component")
+                return _PatternSelector(head, tail, case_sensitive=case_sensitive)
+            case []:
+                return _TerminalSelector()
+        raise ValueError(f"Glob pattern unsupported: {pattern_parts}")
+
+    @abstractmethod
+    def __call__(self, path: T) -> Iterable[T]:
+        raise NotImplementedError()
+
+
+class _TerminalSelector(_Selector):
+    def __call__(self, path: T) -> Iterable[T]:
+        yield path
+
+
+class _NonTerminalSelector(_Selector):
+    __slots__ = (
+        "_dir_only",
+        "_child_selector",
+    )
+
+    def __init__(self, child_pattern_parts: Sequence[str], *, case_sensitive: bool) -> None:
+        super().__init__()
+        if child_pattern_parts:
+            self._child_selector = self.parse(child_pattern_parts, case_sensitive=case_sensitive)
+            self._dir_only = True
+        else:
+            self._child_selector = _TerminalSelector()
+            self._dir_only = False
+
+    def __call__(self, path: T) -> Iterable[T]:
+        if path.is_dir():
+            yield from self._select_children(path)
+
+    @abstractmethod
+    def _select_children(self, path: T) -> Iterable[T]:
+        raise NotImplementedError()
+
+
+class _LiteralSelector(_NonTerminalSelector):
+    __slots__ = ("_literal_path",)
+
+    def __init__(self, path: str, child_pattern_parts: Sequence[str], case_sensitive: bool) -> None:
+        super().__init__(child_pattern_parts, case_sensitive=case_sensitive)
+        self._literal_path = path
+
+    def _select_children(self, path: T) -> Iterable[T]:
+        candidate = path / self._literal_path
+        if self._dir_only and candidate.is_dir() or candidate.exists():
+            yield from self._child_selector(candidate)
+
+
+class _PatternSelector(_NonTerminalSelector):
+    __slots__ = ("_pattern",)
+
+    # The special set of characters that indicate a glob pattern isn't a trivial literal.
+    # Ref: https://docs.python.org/3/library/fnmatch.html#module-fnmatch
+    _glob_specials = re.compile("[*?\\[\\]]")
+
+    @classmethod
+    def needs_pattern(cls, pattern: str) -> bool:
+        return cls._glob_specials.search(pattern) is not None
+
+    @classmethod
+    def compile_pattern(cls, pattern: str, case_sensitive: bool) -> re.Pattern:
+        flags = 0 if case_sensitive else re.IGNORECASE
+        regex = fnmatch.translate(pattern)
+        return re.compile(regex, flags=flags)
+
+    def __init__(self, pattern: str, child_pattern_parts: Sequence[str], case_sensitive: bool) -> None:
+        super().__init__(child_pattern_parts, case_sensitive=case_sensitive)
+        self._pattern = self.compile_pattern(pattern, case_sensitive=case_sensitive)
+
+    def _select_children(self, path: T) -> Iterable[T]:
+        candidates = list(path.iterdir())
+        for candidate in candidates:
+            if self._dir_only and not candidate.is_dir():
+                continue
+            if self._pattern.match(candidate.name):
+                yield from self._child_selector(candidate)
+
+
+class _RecursivePatternSelector(_NonTerminalSelector):
+    def __init__(self, child_pattern_parts: Sequence[str], case_sensitive: bool) -> None:
+        super().__init__(child_pattern_parts, case_sensitive=case_sensitive)
+
+    def _all_directories(self, path: T) -> Iterable[T]:
+        # Depth-first traversal of directory tree, visiting this node first.
+        yield path
+        children = [child for child in path.iterdir() if child.is_dir()]
+        for child in children:
+            yield from self._all_directories(child)
+
+    def _select_children(self, path: T) -> Iterable[T]:
+        yielded = set()
+        for starting_point in self._all_directories(path):
+            for candidate in self._child_selector(starting_point):
+                if candidate not in yielded:
+                    yielded.add(candidate)
+                    yield candidate


Test patch:
diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py
index ca9c200..1a16f14 100644
--- a/tests/unit/test_paths.py
+++ b/tests/unit/test_paths.py
@@ -1,8 +1,12 @@
+import os
+from collections.abc import Iterator
+from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
 from unittest.mock import create_autospec, patch
 
 import pytest
 from databricks.sdk import WorkspaceClient
 from databricks.sdk.errors import NotFound
+from databricks.sdk.mixins.workspace import WorkspaceExt
 from databricks.sdk.service.workspace import (
     ImportFormat,
     Language,
@@ -13,6 +17,399 @@ from databricks.sdk.service.workspace import (
 from databricks.labs.blueprint.paths import WorkspacePath
 
 
+def test_empty_init() -> None:
+    """Ensure that basic initialization works."""
+    ws = create_autospec(WorkspaceClient)
+
+    # Simple initialisation; the empty path is valid.
+    WorkspacePath(ws)
+
+
+@pytest.mark.parametrize(
+    ("args", "expected"),
+    [
+        # Some absolute paths.
+        (["/foo/bar"], ("/", ("/", "foo", "bar"))),
+        (["/", "foo", "bar"], ("/", ("/", "foo", "bar"))),
+        (["/", "foo/bar"], ("/", ("/", "foo", "bar"))),
+        (["/", "foo", "bar/baz"], ("/", ("/", "foo", "bar", "baz"))),
+        # Some relative paths.
+        (["foo/bar"], ("", ("foo", "bar"))),
+        (["foo", "bar"], ("", ("foo", "bar"))),
+        (["foo", "/bar"], ("/", ("/", "bar"))),
+        # Some paths with mixed components of Path types.
+        (["/", "foo", PurePosixPath("bar/baz")], ("/", ("/", "foo", "bar", "baz"))),
+        (["/", "foo", PureWindowsPath("config.sys")], ("/", ("/", "foo", "config.sys"))),
+        (["/", "foo", PurePath("bar")], ("/", ("/", "foo", "bar"))),
+        # Some corner cases: empty, root and trailing '/'.
+        ([], ("", ())),
+        (["/"], ("/", ("/",))),
+        (["/foo/"], ("/", ("/", "foo"))),
+        # Intermediate '.' are supposed to be dropped during normalization.
+        (["/foo/./bar"], ("/", ("/", "foo", "bar"))),
+    ],
+    ids=lambda param: f"WorkspacePath({param!r})" if isinstance(param, list) else repr(param),
+)
+def test_init(args: list[str | PurePath], expected: tuple[str, list[str]]) -> None:
+    """Ensure that initialization with various combinations of segments works as expected."""
+    ws = create_autospec(WorkspaceClient)
+
+    # Run the test.
+    p = WorkspacePath(ws, Path(*args))
+
+    # Validate the initialisation results.
+    assert (p.drive, p.root, p.parts) == ("", *expected)
+
+
+def test_init_error() -> None:
+    """Ensure that we detect initialisation with non-string or path-like path components."""
+    ws = create_autospec(WorkspaceClient)
+
+    expected_msg = "argument should be a str or an os.PathLib object where __fspath__ returns a str, not 'int'"
+    with pytest.raises(TypeError, match=expected_msg):
+        WorkspacePath(ws, 12)  # type: ignore[arg-type]
+
+
+def test_equality() -> None:
+    """Test that Workspace paths can be compared with each other for equality."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/foo/bar") == WorkspacePath(ws, Path("/", "foo", "bar"))
+    assert WorkspacePath(ws, "foo/bar") == WorkspacePath(ws, Path("foo", "bar"))
+    assert WorkspacePath(ws, "/foo/bar") != WorkspacePath(ws, Path("foo", "bar"))
+
+    assert WorkspacePath(ws, "/foo/bar") != Path("foo", "bar")
+    assert Path("/foo/bar") != WorkspacePath(ws, "/foo/bar")
+
+
+def test_hash() -> None:
+    """Test that equal Workspace paths have the same hash value."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert hash(WorkspacePath(ws, "/foo/bar")) == hash(WorkspacePath(ws, Path("/", "foo", "bar")))
+    assert hash(WorkspacePath(ws, "foo/bar")) == hash(WorkspacePath(ws, Path("foo", "bar")))
+
+
+@pytest.mark.parametrize(
+    "increasing_paths",
+    [
+        ("/foo", "/foo/bar", "/foo/baz"),
+        ("foo", "foo/bar", "foo/baz"),
+    ],
+)
+def test_comparison(increasing_paths: tuple[str | list[str], str | list[str], str | list[str]]) -> None:
+    """Test that comparing paths works as expected."""
+    ws = create_autospec(WorkspaceClient)
+
+    p1, p2, p3 = (WorkspacePath(ws, p) for p in increasing_paths)
+    assert p1 < p2 < p3
+    assert p1 <= p2 <= p2 <= p3
+    assert p3 > p2 > p1
+    assert p3 >= p2 >= p2 > p1
+
+
+def test_comparison_errors() -> None:
+    """Test that comparing Workspace paths with other types yields the error we expect, irrespective of comparison order."""
+    ws = create_autospec(WorkspaceClient)
+
+    with pytest.raises(TypeError, match="'<' not supported between instances"):
+        _ = WorkspacePath(ws, "foo") < PurePosixPath("foo")
+    with pytest.raises(TypeError, match="'>' not supported between instances"):
+        _ = WorkspacePath(ws, "foo") > PurePosixPath("foo")
+    with pytest.raises(TypeError, match="'<=' not supported between instances"):
+        _ = WorkspacePath(ws, "foo") <= PurePosixPath("foo")
+    with pytest.raises(TypeError, match="'>=' not supported between instances"):
+        _ = WorkspacePath(ws, "foo") >= PurePosixPath("foo")
+    with pytest.raises(TypeError, match="'<' not supported between instances"):
+        _ = PurePosixPath("foo") < WorkspacePath(ws, "foo")
+    with pytest.raises(TypeError, match="'>' not supported between instances"):
+        _ = PurePosixPath("foo") > WorkspacePath(ws, "foo")
+    with pytest.raises(TypeError, match="'<=' not supported between instances"):
+        _ = PurePosixPath("foo") <= WorkspacePath(ws, "foo")
+    with pytest.raises(TypeError, match="'>=' not supported between instances"):
+        _ = PurePosixPath("foo") >= WorkspacePath(ws, "foo")
+
+
+def test_drive() -> None:
+    """Test that the drive is empty for our paths."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/foo").drive == ""
+    assert WorkspacePath(ws, "foo").root == ""
+
+
+def test_root() -> None:
+    """Test that absolute paths have the '/' root and relative paths do not."""
+    # More comprehensive tests are part of test_init()
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/foo").root == "/"
+    assert WorkspacePath(ws, "foo").root == ""
+
+
+def test_anchor() -> None:
+    """Test that the anchor for absolute paths is '/' and empty for relative paths."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/foo").anchor == "/"
+    assert WorkspacePath(ws, "foo").anchor == ""
+
+
+def test_pathlike_error() -> None:
+    """Paths are "path-like" but Workspace ones aren't, so verify that triggers an error."""
+    ws = create_autospec(WorkspaceClient)
+    p = WorkspacePath(ws, "/some/path")
+
+    with pytest.raises(NotImplementedError, match="Workspace paths are not path-like"):
+        _ = os.fspath(p)
+
+
+def test_name() -> None:
+    """Test that the last part of the path is properly noted as the name."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/foo/bar").name == "bar"
+    assert WorkspacePath(ws, "/foo/").name == "foo"
+    assert WorkspacePath(ws, "/").name == ""
+    assert WorkspacePath(ws, Path()).name == ""
+
+
+def test_parts() -> None:
+    """Test that parts returns the anchor and path components."""
+    # More comprehensive tests are part of test_init()
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/foo/bar").parts == ("/", "foo", "bar")
+    assert WorkspacePath(ws, "/foo/").parts == ("/", "foo")
+    assert WorkspacePath(ws, "/").parts == ("/",)
+    assert WorkspacePath(ws, "foo/bar").parts == ("foo", "bar")
+    assert WorkspacePath(ws, "foo/").parts == ("foo",)
+
+
+def test_suffix() -> None:
+    """Test that the suffix is correctly extracted."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/path/to/distribution.tar.gz").suffix == ".gz"
+    assert WorkspacePath(ws, "/no/suffix/here").suffix == ""
+
+
+def test_suffixes() -> None:
+    """Test that multiple suffixes are correctly extracted."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/path/to/distribution.tar.gz").suffixes == [".tar", ".gz"]
+    assert WorkspacePath(ws, "/path/to/file.txt").suffixes == [".txt"]
+    assert WorkspacePath(ws, "/no/suffix/here").suffixes == []
+
+
+def test_stem() -> None:
+    """Test that the stem is correctly extracted."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/path/to/distribution.tar.gz").stem == "distribution.tar"
+    assert WorkspacePath(ws, "/path/to/file.txt").stem == "file"
+    assert WorkspacePath(ws, "/no/suffix/here").stem == "here"
+
+
+def test_with_name() -> None:
+    """Test that the name in a path can be replaced."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/path/to/notebook.py").with_name("requirements.txt") == WorkspacePath(
+        ws, "/path/to/requirements.txt"
+    )
+    assert WorkspacePath(ws, "relative/notebook.py").with_name("requirements.txt") == WorkspacePath(
+        ws, "relative/requirements.txt"
+    )
+
+
+@pytest.mark.parametrize(
+    ("path", "name"),
+    [
+        # Invalid names.
+        ("/a/path", "invalid/replacement"),
+        ("/a/path", ""),
+        ("/a/path", "."),
+        # Invalid paths for using with_name()
+        ("/", "file.txt"),
+        ("", "file.txt"),
+    ],
+)
+def test_with_name_errors(path, name) -> None:
+    """Test that various forms of invalid .with_name() invocations are detected."""
+    ws = create_autospec(WorkspaceClient)
+
+    with pytest.raises(ValueError):
+        _ = WorkspacePath(ws, path).with_name(name)
+
+
+def test_with_stem() -> None:
+    """Test that the stem in a path can be replaced."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/dir/file.txt").with_stem("README") == WorkspacePath(ws, "/dir/README.txt")
+
+
+def test_with_suffix() -> None:
+    """Test that the suffix of a path can be replaced, and that some errors are handled."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/dir/README.txt").with_suffix(".md") == WorkspacePath(ws, "/dir/README.md")
+    with pytest.raises(ValueError, match="[Ii]nvalid suffix"):
+        _ = WorkspacePath(ws, "/dir/README.txt").with_suffix("txt")
+    with pytest.raises(ValueError, match="empty name"):
+        _ = WorkspacePath(ws, "/").with_suffix(".txt")
+
+
+@pytest.mark.parametrize(
+    ("path", "parent"),
+    [
+        ("/foo/bar/baz", "/foo/bar"),
+        ("/", "/"),
+        (".", "."),
+        ("foo/bar", "foo"),
+        ("foo", "."),
+    ],
+)
+def test_parent(path, parent) -> None:
+    """Test that the parent of a path is properly calculated."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, path).parent == WorkspacePath(ws, parent)
+
+
+@pytest.mark.parametrize(
+    ("path", "parents"),
+    [
+        ("/foo/bar/baz", ("/foo/bar", "/foo", "/")),
+        ("/", ()),
+        (".", ()),
+        ("foo/bar", ("foo", ".")),
+        ("foo", (".",)),
+    ],
+)
+def test_parents(path, parents) -> None:
+    """Test that each of the parents of a path is returned."""
+    ws = create_autospec(WorkspaceClient)
+
+    expected_parents = tuple(WorkspacePath(ws, parent) for parent in parents)
+    assert tuple(WorkspacePath(ws, path).parents) == expected_parents
+
+
+def test_is_relative_to() -> None:
+    """Test detection of whether a path is relative to the target."""
+    ws = create_autospec(WorkspaceClient)
+
+    # Basics where it's true.
+    assert WorkspacePath(ws, "/home/bob").is_relative_to("/")
+    assert WorkspacePath(ws, "/home/bob").is_relative_to("/home")
+    assert WorkspacePath(ws, "/home/bob").is_relative_to("/./home")
+    assert WorkspacePath(ws, "foo/bar/baz").is_relative_to("foo")
+    assert WorkspacePath(ws, "foo/bar/baz").is_relative_to("foo/bar")
+    assert WorkspacePath(ws, "foo/bar/baz").is_relative_to("foo/./bar")
+
+    # Some different situations where it isn't.
+    assert not WorkspacePath(ws, "/home/bob").is_relative_to("home")  # Different anchor.
+    assert not WorkspacePath(ws, "/home/bob").is_relative_to("/usr")  # Not a prefix.
+
+
+def test_is_absolute() -> None:
+    """Test detection of absolute versus relative paths."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/foo/bar").is_absolute()
+    assert WorkspacePath(ws, "/").is_absolute()
+    assert not WorkspacePath(ws, "foo/bar").is_absolute()
+    assert not WorkspacePath(ws, ".").is_absolute()
+
+
+def test_is_reserved() -> None:
+    """Test detection of reserved paths (which don't exist with Workspace paths)."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert not WorkspacePath(ws, "NUL").is_reserved()
+
+
+def test_joinpath() -> None:
+    """Test that paths can be joined."""
+    ws = create_autospec(WorkspaceClient)
+
+    assert WorkspacePath(ws, "/home").joinpath("bob") == WorkspacePath(ws, "/home/bob")
+    assert WorkspacePath(ws, "/home").joinpath(WorkspacePath(ws, "bob")) == WorkspacePath(ws, "/home/bob")
+    assert WorkspacePath(ws, "/home").joinpath(PurePosixPath("bob")) == WorkspacePath(ws, "/home/bob")
+    assert WorkspacePath(ws, "/usr").joinpath("local", "bin") == WorkspacePath(ws, "/usr/local/bin")
+    assert WorkspacePath(ws, "home").joinpath("jane") == WorkspacePath(ws, "home/jane")
+
+
+def test_join_dsl() -> None:
+    """Test that the /-based DSL can be used to build new paths."""
+    ws = create_autospec(WorkspaceClient)
+
+    # First the forward style options.
+    assert WorkspacePath(ws, "/home/bob") / "data" == WorkspacePath(ws, "/home/bob/data")
+    assert WorkspacePath(ws, "/home/bob") / "data" / "base" == WorkspacePath(ws, "/home/bob/data/base")
+    assert WorkspacePath(ws, "/home/bob") / "data/base" == WorkspacePath(ws, "/home/bob/data/base")
+    assert WorkspacePath(ws, "home") / "bob" == WorkspacePath(ws, "home/bob")
+    # New root
+    assert WorkspacePath(ws, "whatever") / "/home" == WorkspacePath(ws, "/home")
+    # Mix types: eventual type is less-associative
+    assert WorkspacePath(ws, "/home/bob") / PurePosixPath("data") == WorkspacePath(ws, "/home/bob/data")
+
+    # Building from the other direction; same as above.
+    assert "/home/bob" / WorkspacePath(ws, "data") == WorkspacePath(ws, "/home/bob/data")
+    assert "/home/bob" / WorkspacePath(ws, "data") / "base" == WorkspacePath(ws, "/home/bob/data/base")
+    assert "/home/bob" / WorkspacePath(ws, "data/base") == WorkspacePath(ws, "/home/bob/data/base")
+    assert "home" / WorkspacePath(ws, "bob") == WorkspacePath(ws, "home/bob")
+    # New root
+    assert "whatever" / WorkspacePath(ws, "/home") == WorkspacePath(ws, "/home")
+    # Mix types: eventual type is less-associative
+    assert PurePosixPath("/home/bob") / WorkspacePath(ws, "data") == PurePosixPath("/home/bob/data")
+
+
+def test_match() -> None:
+    """Test that glob matching works."""
+    ws = create_autospec(WorkspaceClient)
+
+    # Relative patterns, match from the right.
+    assert WorkspacePath(ws, "foo/bar/file.txt").match("*.txt")
+    assert WorkspacePath(ws, "/foo/bar/file.txt").match("bar/*.txt")
+    assert not WorkspacePath(ws, "/foo/bar/file.txt").match("foo/*.txt")
+
+    # Absolute patterns, match from the left (and only against absolute paths)
+    assert WorkspacePath(ws, "/file.txt").match("/*.txt")
+    assert not WorkspacePath(ws, "foo/bar/file.txt").match("/*.txt")
+
+    # Case-sensitive by default, but can be overridden.
+    assert not WorkspacePath(ws, "file.txt").match("*.TXT")
+    # assert WorkspacePath(ws, "file.txt").match("*.TXT", case_sensitive=False)
+
+    # No recursive globs.
+    assert not WorkspacePath(ws, "/foo/bar/file.txt").match("/**/*.txt")
+
+
+def test_iterdir() -> None:
+    """Test that iterating through a directory works."""
+    ws = create_autospec(WorkspaceClient)
+    ws.workspace.list.return_value = iter(
+        (
+            ObjectInfo(path="/home/bob"),
+            ObjectInfo(path="/home/jane"),
+            ObjectInfo(path="/home/ted"),
+            ObjectInfo(path="/home/fred"),
+        )
+    )
+
+    children = set(WorkspacePath(ws, "/home").iterdir())
+
+    assert children == {
+        WorkspacePath(ws, "/home/bob"),
+        WorkspacePath(ws, "/home/jane"),
+        WorkspacePath(ws, "/home/ted"),
+        WorkspacePath(ws, "/home/fred"),
+    }
+
+
 def test_exists_when_path_exists():
     ws = create_autospec(WorkspaceClient)
     workspace_path = WorkspacePath(ws, "/test/path")
@@ -23,7 +420,7 @@ def test_exists_when_path_exists():
 def test_exists_when_path_does_not_exist():
     ws = create_autospec(WorkspaceClient)
     workspace_path = WorkspacePath(ws, "/test/path")
-    ws.workspace.get_status.side_effect = NotFound(...)
+    ws.workspace.get_status.side_effect = NotFound("Simulated NotFound")
     assert not workspace_path.exists()
 
 
@@ -126,22 +523,25 @@ def test_suffix_when_file_has_extension():
 
 def test_suffix_when_file_is_notebook_and_language_matches():
     ws = create_autospec(WorkspaceClient)
+    ws.workspace.get_status.return_value = ObjectInfo(language=Language.PYTHON, object_type=ObjectType.NOTEBOOK)
+
     workspace_path = WorkspacePath(ws, "/test/path")
-    workspace_path._object_info = ObjectInfo(language=Language.PYTHON, object_type=ObjectType.NOTEBOOK)
     assert workspace_path.suffix == ".py"
 
 
 def test_suffix_when_file_is_notebook_and_language_does_not_match():
     ws = create_autospec(WorkspaceClient)
+    ws.workspace.get_status.return_value = ObjectInfo(language=None, object_type=ObjectType.NOTEBOOK)
+
     workspace_path = WorkspacePath(ws, "/test/path")
-    workspace_path._object_info.language = "unknown"
     assert workspace_path.suffix == ""
 
 
 def test_suffix_when_file_is_not_notebook():
     ws = create_autospec(WorkspaceClient)
+    ws.workspace.get_status.return_value = ObjectInfo(language=Language.PYTHON, object_type=ObjectType.FILE)
+
     workspace_path = WorkspacePath(ws, "/test/path")
-    workspace_path.is_notebook = lambda: False
     assert workspace_path.suffix == ""
 
 
@@ -200,16 +600,33 @@ def test_unlink_existing_file():
 def test_unlink_non_existing_file():
     ws = create_autospec(WorkspaceClient)
     workspace_path = WorkspacePath(ws, "/test/path")
-    ws.workspace.get_status.side_effect = NotFound(...)
+    ws.workspace.get_status.side_effect = NotFound("Simulated NotFound")
     with pytest.raises(FileNotFoundError):
         workspace_path.unlink()
 
 
-def test_relative_to():
+def test_relative_to() -> None:
+    """Test that it is possible to get the relative path between two paths."""
     ws = create_autospec(WorkspaceClient)
-    workspace_path = WorkspacePath(ws, "/test/path/subpath")
-    result = workspace_path.relative_to("/test/path")
-    assert str(result) == "subpath"
+
+    # Basics.
+    assert WorkspacePath(ws, "/home/bob").relative_to("/") == WorkspacePath(ws, "home/bob")
+    assert WorkspacePath(ws, "/home/bob").relative_to("/home") == WorkspacePath(ws, "bob")
+    assert WorkspacePath(ws, "/home/bob").relative_to("/./home") == WorkspacePath(ws, "bob")
+    assert WorkspacePath(ws, "foo/bar/baz").relative_to("foo") == WorkspacePath(ws, "bar/baz")
+    assert WorkspacePath(ws, "foo/bar/baz").relative_to("foo/bar") == WorkspacePath(ws, "baz")
+    assert WorkspacePath(ws, "foo/bar/baz").relative_to("foo/./bar") == WorkspacePath(ws, "baz")
+
+    # Walk-up (3.12+) behaviour.
+    assert WorkspacePath(ws, "/home/bob").relative_to("/usr", walk_up=True) == WorkspacePath(ws, "../home/bob")
+
+    # Check some errors.
+    with pytest.raises(ValueError, match="different anchors"):
+        _ = WorkspacePath(ws, "/home/bob").relative_to("home")
+    with pytest.raises(ValueError, match="not in the subpath"):
+        _ = WorkspacePath(ws, "/home/bob").relative_to("/usr")
+    with pytest.raises(ValueError, match="cannot be walked"):
+        _ = WorkspacePath(ws, "/home/bob").relative_to("/home/../usr", walk_up=True)
 
 
 def test_as_fuse_in_databricks_runtime():
@@ -253,7 +670,7 @@ def test_is_dir_when_object_type_is_not_directory():
 def test_is_dir_when_databricks_error_occurs():
     ws = create_autospec(WorkspaceClient)
     workspace_path = WorkspacePath(ws, "/test/path")
-    ws.workspace.get_status.side_effect = NotFound(...)
+    ws.workspace.get_status.side_effect = NotFound("Simulated NotFound")
     assert workspace_path.is_dir() is False
 
 
@@ -274,7 +691,7 @@ def test_is_file_when_object_type_is_not_file():
 def test_is_file_when_databricks_error_occurs():
     ws = create_autospec(WorkspaceClient)
     workspace_path = WorkspacePath(ws, "/test/path")
-    ws.workspace.get_status.side_effect = NotFound(...)
+    ws.workspace.get_status.side_effect = NotFound("Simulated NotFound")
     assert workspace_path.is_file() is False
 
 
@@ -295,10 +712,218 @@ def test_is_notebook_when_object_type_is_not_notebook():
 def test_is_notebook_when_databricks_error_occurs():
     ws = create_autospec(WorkspaceClient)
     workspace_path = WorkspacePath(ws, "/test/path")
-    ws.workspace.get_status.side_effect = NotFound(...)
+    ws.workspace.get_status.side_effect = NotFound("Simulated NotFound")
     assert workspace_path.is_notebook() is False
 
 
+class StubWorkspaceFilesystem:
+    """Stub the basic Workspace filesystem operations."""
+
+    __slots__ = ("_paths",)
+
+    def __init__(self, *known_paths: str | ObjectInfo) -> None:
+        """Initialize a virtual filesystem with a set of known paths.
+
+        Each known path can either be a string or a complete ObjectInfo instance; if a string then the path is
+        treated as a directory if it has a trailing-/ or a file otherwise.
+        """
+        fs_entries = [self._normalize_path(p) for p in known_paths]
+        keyed_entries = {o.path: o for o in fs_entries if o.path is not None}
+        self._normalize_paths(keyed_entries)
+        self._paths = keyed_entries
+
+    @classmethod
+    def _normalize_path(cls, path: str | ObjectInfo) -> ObjectInfo:
+        if isinstance(path, ObjectInfo):
+            return path
+        return ObjectInfo(
+            path=path.rstrip("/") if path != "/" else path,
+            object_type=ObjectType.DIRECTORY if path.endswith("/") else ObjectType.FILE,
+        )
+
+    @classmethod
+    def _normalize_paths(cls, paths: dict[str, ObjectInfo]) -> None:
+        """Validate entries are absolute and that intermediate directories are both present and typed correctly."""
+        for p in list(paths):
+            for parent in PurePath(p).parents:
+                path = str(parent)
+                paths.setdefault(path, ObjectInfo(path=path, object_type=ObjectType.DIRECTORY))
+
+    def _stub_get_status(self, path: str) -> ObjectInfo:
+        object_info = self._paths.get(path)
+        if object_info is None:
+            msg = f"Simulated path not found: {path}"
+            raise NotFound(msg)
+        return object_info
+
+    def _stub_list(
+        self, path: str, *, notebooks_modified_after: int | None = None, recursive: bool | None = False, **kwargs
+    ) -> Iterator[ObjectInfo]:
+        path = path.rstrip("/")
+        path_len = len(path)
+        for candidate, object_info in self._paths.items():
+            # Only direct children, and excluding the path itself.
+            if (
+                len(candidate) > (path_len + 1)
+                and candidate[:path_len] == path
+                and candidate[path_len] == "/"
+                and "/" not in candidate[path_len + 1 :]
+            ):
+                yield object_info
+
+    def mock(self) -> WorkspaceExt:
+        m = create_autospec(WorkspaceExt)
+        m.get_status.side_effect = self._stub_get_status
+        m.list.side_effect = self._stub_list
+        return m
+
+
+def test_globbing_literals() -> None:
+    """Verify that trivial (literal) globs for one or more path segments match (or doesn't)."""
+    ws = create_autospec(WorkspaceClient)
+    ws.workspace = StubWorkspaceFilesystem("/home/bob/bin/labs", "/etc/passwd").mock()
+
+    assert set(WorkspacePath(ws, "/home").glob("jane")) == set()
+    assert set(WorkspacePath(ws, "/home").glob("bob")) == {WorkspacePath(ws, "/home/bob")}
+    assert set(WorkspacePath(ws, "/home").glob("bob/")) == {WorkspacePath(ws, "/home/bob")}
+    assert set(WorkspacePath(ws, "/home").glob("bob/bin")) == {WorkspacePath(ws, "/home/bob/bin")}
+    assert set(WorkspacePath(ws, "/home").glob("bob/bin/labs/")) == set()
+    assert set(WorkspacePath(ws, "/etc").glob("passwd")) == {WorkspacePath(ws, "/etc/passwd")}
+
+
+def test_globbing_empty_error() -> None:
+    """Verify that an empty glob triggers an immediate error."""
+    ws = create_autospec(WorkspaceClient)
+
+    with pytest.raises(ValueError, match="must not be empty"):
+        _ = set(WorkspacePath(ws, "/etc/passwd").glob(""))
+
+
+def test_globbing_absolute_error() -> None:
+    """Verify that absolute-path globs triggers an immediate error."""
+    ws = create_autospec(WorkspaceClient)
+
+    with pytest.raises(NotImplementedError, match="Non-relative patterns are unsupported"):
+        _ = set(WorkspacePath(ws, "/").glob("/tmp/*"))
+
+
+def test_globbing_patterns() -> None:
+    """Verify that globbing with globs works as expected, including across multiple path segments."""
+    ws = create_autospec(WorkspaceClient)
+    ws.workspace = StubWorkspaceFilesystem(
+        "/home/bob/bin/labs",
+        "/home/bob/bin/databricks",
+        "/home/bot/",
+        "/home/bat/",
+        "/etc/passwd",
+    ).mock()
+
+    assert set(WorkspacePath(ws, "/home").glob("*")) == {
+        WorkspacePath(ws, "/home/bob"),
+        WorkspacePath(ws, "/home/bot"),
+        WorkspacePath(ws, "/home/bat"),
+    }
+    assert set(WorkspacePath(ws, "/home/bob/bin").glob("*")) == {
+        WorkspacePath(ws, "/home/bob/bin/databricks"),
+        WorkspacePath(ws, "/home/bob/bin/labs"),
+    }
+    assert set(WorkspacePath(ws, "/home/bob").glob("*/*")) == {
+        WorkspacePath(ws, "/home/bob/bin/databricks"),
+        WorkspacePath(ws, "/home/bob/bin/labs"),
+    }
+    assert set(WorkspacePath(ws, "/home/bob/bin").glob("*a*")) == {
+        WorkspacePath(ws, "/home/bob/bin/databricks"),
+        WorkspacePath(ws, "/home/bob/bin/labs"),
+    }
+    assert set(WorkspacePath(ws, "/home").glob("bo[bt]")) == {
+        WorkspacePath(ws, "/home/bob"),
+        WorkspacePath(ws, "/home/bot"),
+    }
+    assert set(WorkspacePath(ws, "/home").glob("b[!o]t")) == {WorkspacePath(ws, "/home/bat")}
+
+
+def test_glob_trailing_slash() -> None:
+    """Verify that globs with a trailing slash only match directories."""
+    ws = create_autospec(WorkspaceClient)
+    ws.workspace = StubWorkspaceFilesystem(
+        "/home/bob/bin/labs",
+        "/home/bob/bin/databricks",
+        "/home/bob/.profile",
+    ).mock()
+
+    assert set(WorkspacePath(ws, "/home/bob").glob("*/")) == {WorkspacePath(ws, "/home/bob/bin")}
+    assert set(WorkspacePath(ws, "/home").glob("bob/*/")) == {WorkspacePath(ws, "/home/bob/bin")}
+    # Negative test.
+    assert WorkspacePath(ws, "/home/bob/.profile") in set(WorkspacePath(ws, "/home").glob("bob/*"))
+
+
+def test_glob_parent_path_traversal_error() -> None:
+    """Globs are normally allowed to include /../ segments to traverse directories; these aren't supported though."""
+    ws = create_autospec(WorkspaceClient)
+
+    with pytest.raises(ValueError, match="Parent traversal is not supported"):
+        _ = set(WorkspacePath(ws, "/usr").glob("sbin/../bin"))
+
+
+def test_recursive_glob() -> None:
+    """Verify that recursive globs work properly."""
+    ws = create_autospec(WorkspaceClient)
+    ws.workspace = StubWorkspaceFilesystem(
+        "/home/bob/bin/labs",
+        "/usr/local/bin/labs",
+        "/usr/local/sbin/labs",
+    ).mock()
+
+    assert set(WorkspacePath(ws, "/").glob("**/bin/labs")) == {
+        WorkspacePath(ws, "/home/bob/bin/labs"),
+        WorkspacePath(ws, "/usr/local/bin/labs"),
+    }
+    assert set(WorkspacePath(ws, "/").glob("usr/**/labs")) == {
+        WorkspacePath(ws, "/usr/local/bin/labs"),
+        WorkspacePath(ws, "/usr/local/sbin/labs"),
+    }
+    assert set(WorkspacePath(ws, "/").glob("usr/**")) == {
+        WorkspacePath(ws, "/usr"),
+        WorkspacePath(ws, "/usr/local"),
+        WorkspacePath(ws, "/usr/local/bin"),
+        WorkspacePath(ws, "/usr/local/sbin"),
+    }
+
+
+def test_double_recursive_glob() -> None:
+    """Verify that double-recursive globs work as expected without duplicate results."""
+    ws = create_autospec(WorkspaceClient)
+    ws.workspace = StubWorkspaceFilesystem(
+        "/some/long/path/with/repeated/path/segments/present",
+    ).mock()
+
+    assert tuple(WorkspacePath(ws, "/").glob("**/path/**/present")) == (
+        WorkspacePath(ws, "/some/long/path/with/repeated/path/segments/present"),
+    )
+
+
+def test_glob_case_insensitive() -> None:
+    """As of python 3.12, globbing is allowed to be case-insensitive irrespective of the underlying filesystem. Check this."""
+    ws = create_autospec(WorkspaceClient)
+    ws.workspace = StubWorkspaceFilesystem(
+        "/home/bob/bin/labs",
+        "/home/bob/bin/databricks",
+        "/home/bot/",
+        "/home/bat/",
+        "/etc/passwd",
+    ).mock()
+
+    assert set(WorkspacePath(ws, "/home").glob("B*t", case_sensitive=False)) == {
+        WorkspacePath(ws, "/home/bot"),
+        WorkspacePath(ws, "/home/bat"),
+    }
+    assert set(WorkspacePath(ws, "/home").glob("bO[TB]", case_sensitive=False)) == {
+        WorkspacePath(ws, "/home/bot"),
+        WorkspacePath(ws, "/home/bob"),
+    }
+    assert set(WorkspacePath(ws, "/etc").glob("PasSWd", case_sensitive=False)) == {WorkspacePath(ws, "/etc/passwd")}
+
+
 def test_globbing_when_nested_json_files_exist():
     ws = create_autospec(WorkspaceClient)
     workspace_path = WorkspacePath(ws, "/test/path")
@@ -317,3 +942,16 @@ def test_globbing_when_nested_json_files_exist():
     ]
     result = [str(p) for p in workspace_path.glob("*/*.json")]
     assert result == ["/test/path/dir1/file1.json", "/test/path/dir2/file2.json"]
+
+
+def test_rglob() -> None:
+    ws = create_autospec(WorkspaceClient)
+    ws.workspace = StubWorkspaceFilesystem(
+        "/test/path/dir1/file1.json",
+        "/test/path/dir2/file2.json",
+    ).mock()
+
+    assert set(WorkspacePath(ws, "/test").rglob("*.json")) == {
+        WorkspacePath(ws, "/test/path/dir1/file1.json"),
+        WorkspacePath(ws, "/test/path/dir2/file2.json"),
+    }