Repository: drivendataorg/cloudpathlib
Pull request #364 (2023-09-22 21:54:46)

Add support for Python 3.12
Adding support for Python 3.12. See the release notes for changes to `pathlib`:
https://docs.python.org/3.12/whatsnew/3.12.html#pathlib

Dev Notes:
Needed to install some particular versions/dev versions of packages to get test suite to work
 - `pytest-cases` from this PR https://github.com/smarie/python-pytest-cases/pull/312

Done:
 - [x] update glob code that depends on pathlib internals
 - [x] `case_insensitive` for `glob`, `rglob` and `match`
 - [x] add `is_junction` method for parity
 - [x] rename `joinpath` arg collection for parity
 - [x] add `walk_up` kwarg to `relative_to`
 - [x] add `with_segments` method
 - [x] Add test that compares public `Path` and `CloudPath` APIs for compatibility
 - [x] ~`walk_up` for `relative_to` probably needs its own logic since parents are not necessarily in the local `cache`~ (not true, we cache up to the bucket so it should work)
 - [x] `CloudPath.walk` method
 - [x] Tests for updated function signatures and added methods
 - [x] Update CI to run 3.12
 - [x] Update docs methods table


----------------------------------

Since we updated glob logic, I also ran the perf tests. "Glob deep recursive" is a bit slower, but I'm not sure that we can avoid that at the moment.

Before (see https://github.com/drivendataorg/cloudpathlib/pull/318)

```
                                  Performance suite results: (2023-09-22T13:35:28.198434)
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
┃ Test Name      ┃ Config Name                ┃ Iterations ┃           Mean ┃              Std ┃            Max ┃ N Items ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
│ List Folders   │ List shallow recursive     │         10 │ 0:00:00.916454 │ ± 0:00:00.024464 │ 0:00:00.960438 │   5,500 │
│ List Folders   │ List shallow non-recursive │         10 │ 0:00:00.873428 │ ± 0:00:00.079697 │ 0:00:01.077733 │   5,500 │
│ List Folders   │ List normal recursive      │         10 │ 0:00:01.242709 │ ± 0:00:00.096424 │ 0:00:01.488026 │   7,877 │
│ List Folders   │ List normal non-recursive  │         10 │ 0:00:00.058277 │ ± 0:00:00.002522 │ 0:00:00.061377 │     113 │
│ List Folders   │ List deep recursive        │         10 │ 0:00:02.123229 │ ± 0:00:00.200553 │ 0:00:02.563560 │   7,955 │
│ List Folders   │ List deep non-recursive    │         10 │ 0:00:00.055756 │ ± 0:00:00.001919 │ 0:00:00.059095 │      31 │
│ Glob scenarios │ Glob shallow recursive     │         10 │ 0:00:00.968862 │ ± 0:00:00.088358 │ 0:00:01.202280 │   5,500 │
│ Glob scenarios │ Glob shallow non-recursive │         10 │ 0:00:00.932189 │ ± 0:00:00.077164 │ 0:00:01.147067 │   5,500 │
│ Glob scenarios │ Glob normal recursive      │         10 │ 0:00:01.313051 │ ± 0:00:00.111289 │ 0:00:01.627896 │   7,272 │
│ Glob scenarios │ Glob normal non-recursive  │         10 │ 0:00:00.058946 │ ± 0:00:00.002293 │ 0:00:00.063950 │      12 │
│ Glob scenarios │ Glob deep recursive        │         10 │ 0:00:02.138108 │ ± 0:00:00.237426 │ 0:00:02.584835 │   7,650 │
│ Glob scenarios │ Glob deep non-recursive    │         10 │ 0:00:00.060686 │ ± 0:00:00.003718 │ 0:00:00.067627 │      25 │
└────────────────┴────────────────────────────┴────────────┴────────────────┴──────────────────┴────────────────┴─────────┘
```

After:

```
                                  Performance suite results: (2023-09-22T13:43:47.104606)
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
┃ Test Name      ┃ Config Name                ┃ Iterations ┃           Mean ┃              Std ┃            Max ┃ N Items ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
│ List Folders   │ List shallow recursive     │         10 │ 0:00:00.877952 │ ± 0:00:00.028691 │ 0:00:00.920189 │   5,500 │
│ List Folders   │ List shallow non-recursive │         10 │ 0:00:00.934447 │ ± 0:00:00.108835 │ 0:00:01.238155 │   5,500 │
│ List Folders   │ List normal recursive      │         10 │ 0:00:01.337555 │ ± 0:00:00.104314 │ 0:00:01.622105 │   7,877 │
│ List Folders   │ List normal non-recursive  │         10 │ 0:00:00.059800 │ ± 0:00:00.002492 │ 0:00:00.062586 │     113 │
│ List Folders   │ List deep recursive        │         10 │ 0:00:02.150453 │ ± 0:00:00.110567 │ 0:00:02.457655 │   7,955 │
│ List Folders   │ List deep non-recursive    │         10 │ 0:00:00.058764 │ ± 0:00:00.003476 │ 0:00:00.066115 │      31 │
│ Glob scenarios │ Glob shallow recursive     │         10 │ 0:00:01.060310 │ ± 0:00:00.105645 │ 0:00:01.349325 │   5,500 │
│ Glob scenarios │ Glob shallow non-recursive │         10 │ 0:00:01.067021 │ ± 0:00:00.155781 │ 0:00:01.413021 │   5,500 │
│ Glob scenarios │ Glob normal recursive      │         10 │ 0:00:01.462447 │ ± 0:00:00.148627 │ 0:00:01.865197 │   7,272 │
│ Glob scenarios │ Glob normal non-recursive  │         10 │ 0:00:00.067512 │ ± 0:00:00.003345 │ 0:00:00.071471 │      12 │
│ Glob scenarios │ Glob deep recursive        │         10 │ 0:00:02.918050 │ ± 0:00:00.136361 │ 0:00:03.278188 │   7,650 │
│ Glob scenarios │ Glob deep non-recursive    │         10 │ 0:00:00.065398 │ ± 0:00:00.003693 │ 0:00:00.069389 │      25 │
└────────────────┴────────────────────────────┴────────────┴────────────────┴──────────────────┴────────────────┴─────────┘
```

Performance for `walk` implementation is nearly identical to recursive `glob`:

```
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
┃ Test Name      ┃ Config Name  ┃ Iterations ┃           Mean ┃              Std ┃            Max ┃ N Items ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
│ Walk scenarios │ Walk shallow │         10 │ 0:00:00.969727 │ ± 0:00:00.023558 │ 0:00:01.020252 │   5,500 │
│ Walk scenarios │ Walk normal  │         10 │ 0:00:01.449814 │ ± 0:00:00.079976 │ 0:00:01.653003 │   7,272 │
│ Walk scenarios │ Walk deep    │         10 │ 0:00:02.951866 │ ± 0:00:00.182130 │ 0:00:03.231099 │   7,650 │
└────────────────┴──────────────┴────────────┴────────────────┴──────────────────┴────────────────┴─────────┘

```



github-actions[bot]: <!-- NETLIFY DEPLOY COMMENT GENERATED BY ACTIONS_NETLIFY - APP ID SHA256: 9d0998be292ddace9530fffad95434f4e2279530e25cf278a25f07954faa426e -->
🚀 Deployed on https://deploy-preview-364--gallant-agnesi-5f7bb3.netlify.app
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/drivendataorg/cloudpathlib/pull/364?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=drivendataorg) Report
> Merging [#364](https://app.codecov.io/gh/drivendataorg/cloudpathlib/pull/364?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=drivendataorg) (fc21d15) into [master](https://app.codecov.io/gh/drivendataorg/cloudpathlib/commit/e6ae9fa831c1da9836a0a942e633c9e8b29fbbd9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=drivendataorg) (e6ae9fa) will **decrease** coverage by `1.0%`.
> The diff coverage is `68.5%`.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff            @@
##           master    #364     +/-   ##
========================================
- Coverage    93.7%   92.8%   -1.0%     
========================================
  Files          22      22             
  Lines        1512    1537     +25     
========================================
+ Hits         1418    1427      +9     
- Misses         94     110     +16     
```


| [Files Changed](https://app.codecov.io/gh/drivendataorg/cloudpathlib/pull/364?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=drivendataorg) | Coverage Δ | |
|---|---|---|
| [cloudpathlib/cloudpath.py](https://app.codecov.io/gh/drivendataorg/cloudpathlib/pull/364?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=drivendataorg#diff-Y2xvdWRwYXRobGliL2Nsb3VkcGF0aC5weQ==) | `90.4% <68.5%> (-1.6%)` | :arrow_down: |

... and [3 files with indirect coverage changes](https://app.codecov.io/gh/drivendataorg/cloudpathlib/pull/364/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=drivendataorg)

</details>
pjbull: @jayqi I think 3.12 compatibility should be good to go with this PR now!

Patch:
diff --git a/HISTORY.md b/HISTORY.md
index 25aee0e..978a70b 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,9 +1,10 @@
 # cloudpathlib Changelog
 
-## UNRELEASED
+## v0.16.0 (2023-10-08)
  - Add "CloudPath" as return type on `__init__` for mypy issues. ([Issue #179](https://github.com/drivendataorg/cloudpathlib/issues/179), [PR #342](https://github.com/drivendataorg/cloudpathlib/pull/342))
  - Add `with_stem` to all path types when python version supports it (>=3.9). ([Issue #287](https://github.com/drivendataorg/cloudpathlib/issues/287), [PR #290](https://github.com/drivendataorg/cloudpathlib/pull/290), thanks to [@Gilthans](https://github.com/Gilthans)) 
  - Add `newline` parameter to the `write_text` method to align to `pathlib` functionality as of Python 3.10. [PR #362]https://github.com/drivendataorg/cloudpathlib/pull/362), thanks to [@pricemg](https://github.com/pricemg).
+ - Add support for Python 3.12 ([PR #364](https://github.com/drivendataorg/cloudpathlib/pull/364))
 
 ## v0.15.1 (2023-07-12)
 
diff --git a/README.md b/README.md
index 1343a84..ffde207 100644
--- a/README.md
+++ b/README.md
@@ -135,6 +135,7 @@ Most methods and properties from `pathlib.Path` are supported except for the one
 | `is_absolute`          | ✅                | ✅         | ✅         |
 | `is_dir`               | ✅                | ✅         | ✅         |
 | `is_file`              | ✅                | ✅         | ✅         |
+| `is_junction`          | ✅                | ✅         | ✅         |
 | `is_relative_to`       | ✅                | ✅         | ✅         |
 | `iterdir`              | ✅                | ✅         | ✅         |
 | `joinpath`             | ✅                | ✅         | ✅         |
@@ -160,7 +161,9 @@ Most methods and properties from `pathlib.Path` are supported except for the one
 | `suffixes`             | ✅                | ✅         | ✅         |
 | `touch`                | ✅                | ✅         | ✅         |
 | `unlink`               | ✅                | ✅         | ✅         |
+| `walk`                 | ✅                | ✅         | ✅         |
 | `with_name`            | ✅                | ✅         | ✅         |
+| `with_segments`        | ✅                | ✅         | ✅         |
 | `with_stem`            | ✅                | ✅         | ✅         |
 | `with_suffix`          | ✅                | ✅         | ✅         |
 | `write_bytes`          | ✅                | ✅         | ✅         |
@@ -170,6 +173,7 @@ Most methods and properties from `pathlib.Path` are supported except for the one
 | `cwd`                  | ❌                | ❌         | ❌         |
 | `expanduser`           | ❌                | ❌         | ❌         |
 | `group`                | ❌                | ❌         | ❌         |
+| `hardlink_to`          | ❌                | ❌         | ❌         |
 | `home`                 | ❌                | ❌         | ❌         |
 | `is_block_device`      | ❌                | ❌         | ❌         |
 | `is_char_device`       | ❌                | ❌         | ❌         |
@@ -179,7 +183,6 @@ Most methods and properties from `pathlib.Path` are supported except for the one
 | `is_socket`            | ❌                | ❌         | ❌         |
 | `is_symlink`           | ❌                | ❌         | ❌         |
 | `lchmod`               | ❌                | ❌         | ❌         |
-| `link_to`              | ❌                | ❌         | ❌         |
 | `lstat`                | ❌                | ❌         | ❌         |
 | `owner`                | ❌                | ❌         | ❌         |
 | `readlink`             | ❌                | ❌         | ❌         |
diff --git a/cloudpathlib/cloudpath.py b/cloudpathlib/cloudpath.py
index b2241b6..4e322fa 100644
--- a/cloudpathlib/cloudpath.py
+++ b/cloudpathlib/cloudpath.py
@@ -8,10 +8,9 @@ from pathlib import (  # type: ignore
     PosixPath,
     PurePosixPath,
     WindowsPath,
-    _make_selector,
-    _posix_flavour,
     _PathParents,
 )
+
 import shutil
 import sys
 from typing import (
@@ -44,6 +43,17 @@ if sys.version_info >= (3, 11):
 else:
     from typing_extensions import Self
 
+if sys.version_info >= (3, 12):
+    from pathlib import posixpath as _posix_flavour  # type: ignore[attr-defined]
+    from pathlib import _make_selector  # type: ignore[attr-defined]
+else:
+    from pathlib import _posix_flavour  # type: ignore[attr-defined]
+    from pathlib import _make_selector as _make_selector_pathlib  # type: ignore[attr-defined]
+
+    def _make_selector(pattern_parts, _flavour, case_sensitive=True):
+        return _make_selector_pathlib(tuple(pattern_parts), _flavour)
+
+
 from cloudpathlib.enums import FileCacheMode
 
 from . import anypath
@@ -342,6 +352,8 @@ class CloudPath(metaclass=CloudPathMeta):
     # owner - no cloud equivalent
     # root - drive already has the bucket and anchor/prefix has the scheme, so nothing to store here
     # symlink_to - no cloud equivalent
+    # link_to - no cloud equivalent
+    # hardlink_to - no cloud equivalent
 
     # ====================== REQUIRED, NOT GENERIC ======================
     # Methods that must be implemented, but have no generic application
@@ -406,7 +418,7 @@ class CloudPath(metaclass=CloudPathMeta):
                 ".glob is only supported within a bucket or container; you can use `.iterdir` to list buckets; for example, CloudPath('s3://').iterdir()"
             )
 
-    def _glob(self, selector, recursive: bool) -> Generator[Self, None, None]:
+    def _build_subtree(self, recursive):
         # build a tree structure for all files out of default dicts
         Tree: Callable = lambda: defaultdict(Tree)
 
@@ -433,7 +445,10 @@ class CloudPath(metaclass=CloudPathMeta):
             nodes = (p for p in parts)
             _build_tree(file_tree, next(nodes, None), nodes, is_dir)
 
-        file_tree = dict(file_tree)  # freeze as normal dict before passing in
+        return dict(file_tree)  # freeze as normal dict before passing in
+
+    def _glob(self, selector, recursive: bool) -> Generator[Self, None, None]:
+        file_tree = self._build_subtree(recursive)
 
         root = _CloudPathSelectable(
             self.name,
@@ -445,11 +460,15 @@ class CloudPath(metaclass=CloudPathMeta):
             # select_from returns self.name/... so strip before joining
             yield (self / str(p)[len(self.name) + 1 :])
 
-    def glob(self, pattern: str) -> Generator[Self, None, None]:
+    def glob(
+        self, pattern: str, case_sensitive: Optional[bool] = None
+    ) -> Generator[Self, None, None]:
         self._glob_checks(pattern)
 
         pattern_parts = PurePosixPath(pattern).parts
-        selector = _make_selector(tuple(pattern_parts), _posix_flavour)
+        selector = _make_selector(
+            tuple(pattern_parts), _posix_flavour, case_sensitive=case_sensitive
+        )
 
         yield from self._glob(
             selector,
@@ -458,11 +477,15 @@ class CloudPath(metaclass=CloudPathMeta):
             in pattern,  # recursive listing needed if explicit ** or any sub folder in pattern
         )
 
-    def rglob(self, pattern: str) -> Generator[Self, None, None]:
+    def rglob(
+        self, pattern: str, case_sensitive: Optional[bool] = None
+    ) -> Generator[Self, None, None]:
         self._glob_checks(pattern)
 
         pattern_parts = PurePosixPath(pattern).parts
-        selector = _make_selector(("**",) + tuple(pattern_parts), _posix_flavour)
+        selector = _make_selector(
+            ("**",) + tuple(pattern_parts), _posix_flavour, case_sensitive=case_sensitive
+        )
 
         yield from self._glob(selector, True)
 
@@ -471,6 +494,41 @@ class CloudPath(metaclass=CloudPathMeta):
             if f != self:  # iterdir does not include itself in pathlib
                 yield f
 
+    @staticmethod
+    def _walk_results_from_tree(root, tree, top_down=True):
+        """Utility to yield tuples in the form expected by `.walk` from the file
+        tree constructed by `_build_substree`.
+        """
+        dirs = []
+        files = []
+        for item, branch in tree.items():
+            files.append(item) if branch is None else dirs.append(item)
+
+        if top_down:
+            yield root, dirs, files
+
+        for dir in dirs:
+            yield from CloudPath._walk_results_from_tree(root / dir, tree[dir], top_down=top_down)
+
+        if not top_down:
+            yield root, dirs, files
+
+    def walk(
+        self,
+        top_down: bool = True,
+        on_error: Optional[Callable] = None,
+        follow_symlinks: bool = False,
+    ) -> Generator[Tuple[Self, List[str], List[str]], None, None]:
+        try:
+            file_tree = self._build_subtree(recursive=True)  # walking is always recursive
+            yield from self._walk_results_from_tree(self, file_tree, top_down=top_down)
+
+        except Exception as e:
+            if on_error is not None:
+                on_error(e)
+            else:
+                raise
+
     def open(
         self,
         mode: str = "r",
@@ -647,6 +705,9 @@ class CloudPath(metaclass=CloudPathMeta):
         with self.open(mode="r", encoding=encoding, errors=errors) as f:
             return f.read()
 
+    def is_junction(self):
+        return False  # only windows paths can be junctions, not cloudpaths
+
     # ====================== DISPATCHED TO POSIXPATH FOR PURE PATHS ======================
     # Methods that are dispatched to exactly how pathlib.PurePosixPath would calculate it on
     # self._path for pure paths (does not matter if file exists);
@@ -692,8 +753,8 @@ class CloudPath(metaclass=CloudPathMeta):
 
         return self._dispatch_to_path("__truediv__", other)
 
-    def joinpath(self, *args: Union[str, os.PathLike]) -> Self:
-        return self._dispatch_to_path("joinpath", *args)
+    def joinpath(self, *pathsegments: Union[str, os.PathLike]) -> Self:
+        return self._dispatch_to_path("joinpath", *pathsegments)
 
     def absolute(self) -> Self:
         return self
@@ -704,7 +765,7 @@ class CloudPath(metaclass=CloudPathMeta):
     def resolve(self, strict: bool = False) -> Self:
         return self
 
-    def relative_to(self, other: Self) -> PurePosixPath:
+    def relative_to(self, other: Self, walk_up: bool = False) -> PurePosixPath:
         # We don't dispatch regularly since this never returns a cloud path (since it is relative, and cloud paths are
         # absolute)
         if not isinstance(other, CloudPath):
@@ -713,7 +774,13 @@ class CloudPath(metaclass=CloudPathMeta):
             raise ValueError(
                 f"{self} is a {self.cloud_prefix} path, but {other} is a {other.cloud_prefix} path"
             )
-        return self._path.relative_to(other._path)
+
+        kwargs = dict(walk_up=walk_up)
+
+        if sys.version_info < (3, 12):
+            kwargs.pop("walk_up")
+
+        return self._path.relative_to(other._path, **kwargs)  # type: ignore[call-arg]
 
     def is_relative_to(self, other: Self) -> bool:
         try:
@@ -726,12 +793,17 @@ class CloudPath(metaclass=CloudPathMeta):
     def name(self) -> str:
         return self._dispatch_to_path("name")
 
-    def match(self, path_pattern: str) -> bool:
+    def match(self, path_pattern: str, case_sensitive: Optional[bool] = None) -> bool:
         # strip scheme from start of pattern before testing
         if path_pattern.startswith(self.anchor + self.drive + "/"):
             path_pattern = path_pattern[len(self.anchor + self.drive + "/") :]
 
-        return self._dispatch_to_path("match", path_pattern)
+        kwargs = dict(case_sensitive=case_sensitive)
+
+        if sys.version_info < (3, 12):
+            kwargs.pop("case_sensitive")
+
+        return self._dispatch_to_path("match", path_pattern, **kwargs)
 
     @property
     def parent(self) -> Self:
@@ -771,6 +843,12 @@ class CloudPath(metaclass=CloudPathMeta):
     def with_name(self, name: str) -> Self:
         return self._dispatch_to_path("with_name", name)
 
+    def with_segments(self, *pathsegments) -> Self:
+        """Create a new CloudPath with the same client out of the given segments.
+        The first segment will be interpreted as the bucket/container name.
+        """
+        return self._new_cloudpath("/".join(pathsegments))
+
     def with_suffix(self, suffix: str) -> Self:
         return self._dispatch_to_path("with_suffix", suffix)
 
@@ -1244,3 +1322,16 @@ class _CloudPathSelectable:
         )
 
     _scandir = scandir  # Py 3.11 compatibility
+
+    def walk(self):
+        # split into dirs and files
+        dirs_files = defaultdict(list)
+        with self.scandir(self) as items:
+            for child in items:
+                dirs_files[child.is_dir()].append(child)
+
+            # top-down, so yield self before recursive call
+            yield self, [f.name for f in dirs_files[True]], [f.name for f in dirs_files[False]]
+
+            for child_dir in dirs_files[True]:
+                yield from child_dir.walk()
diff --git a/pyproject.toml b/pyproject.toml
index 998de78..1aec1d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,7 @@ classifiers = [
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
+  "Programming Language :: Python :: 3.12",
 ]
 requires-python = ">=3.7"
 dependencies = [
@@ -49,7 +50,7 @@ all = ["cloudpathlib[azure]", "cloudpathlib[gs]", "cloudpathlib[s3]"]
 
 [tool.black]
 line-length = 99
-target-version = ['py37', 'py38', 'py39', 'py310', 'py311']
+target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312']
 include = '\.pyi?$|\.ipynb$'
 extend-exclude = '''
 /(
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 3acae4f..ade42b3 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -20,7 +20,8 @@ pillow
 psutil
 pydantic
 pytest
-pytest-cases
+# pytest-cases
+git+https://github.com/jayqi/python-pytest-cases@packaging-version
 pytest-cov
 pytest-xdist
 python-dotenv


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 6918cf6..708bf4a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -42,7 +42,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: [3.7, 3.8, 3.9, "3.10", "3.11"]
+        python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"]
 
     steps:
       - uses: actions/checkout@v3
diff --git a/tests/performance/perf_file_listing.py b/tests/performance/perf_file_listing.py
index c555b61..3551a85 100644
--- a/tests/performance/perf_file_listing.py
+++ b/tests/performance/perf_file_listing.py
@@ -10,3 +10,12 @@ def glob(folder, recursive):
         return {"n_items": len(list(folder.rglob("*.item")))}
     else:
         return {"n_items": len(list(folder.glob("*.item")))}
+
+
+def walk(folder):
+    n_items = 0
+
+    for _, _, files in folder.walk():
+        n_items += len(files)
+
+    return {"n_items": n_items}
diff --git a/tests/performance/runner.py b/tests/performance/runner.py
index 494882f..859b549 100644
--- a/tests/performance/runner.py
+++ b/tests/performance/runner.py
@@ -14,7 +14,7 @@ from loguru import logger
 from cloudpathlib import CloudPath
 
 
-from perf_file_listing import folder_list, glob
+from perf_file_listing import folder_list, glob, walk
 
 
 # make loguru and tqdm play nicely together
@@ -137,6 +137,15 @@ def main(root, iterations, burn_in):
                 PerfRunConfig(name="Glob deep non-recursive", args=[deep, False], kwargs={}),
             ],
         ),
+        (
+            "Walk scenarios",
+            walk,
+            [
+                PerfRunConfig(name="Walk shallow", args=[shallow], kwargs={}),
+                PerfRunConfig(name="Walk normal", args=[normal], kwargs={}),
+                PerfRunConfig(name="Walk deep", args=[deep], kwargs={}),
+            ],
+        ),
     ]
 
     logger.info(
diff --git a/tests/test_caching.py b/tests/test_caching.py
index 607e3da..1532112 100644
--- a/tests/test_caching.py
+++ b/tests/test_caching.py
@@ -231,6 +231,7 @@ def test_interaction_with_local_cache_dir(rig: CloudProviderTestRig, tmpdir):
     assert cp.client.file_cache_mode == FileCacheMode.tmp_dir
 
     # download from cloud into the cache
+    sleep(0.1)  # test can be flaky saing that the cache dir doesn't exist yet
     with cp.open("r") as f:
         _ = f.read()
 
diff --git a/tests/test_cloudpath_file_io.py b/tests/test_cloudpath_file_io.py
index 8b9a705..af8f9e6 100644
--- a/tests/test_cloudpath_file_io.py
+++ b/tests/test_cloudpath_file_io.py
@@ -1,8 +1,9 @@
 from datetime import datetime
 import os
-from pathlib import PurePosixPath
+from pathlib import Path, PurePosixPath
 import pickle
 from shutil import rmtree
+import sys
 from time import sleep
 
 import pytest
@@ -104,11 +105,12 @@ def glob_test_dirs(rig, tmp_path):
     rmtree(local_root)
 
 
-def _assert_glob_results_match(cloud_results, local_results, cloud_root, local_root):
-    def _lstrip_path_root(path, root):
-        rel_path = str(path)[len(str(root)) :]
-        return rel_path.rstrip("/")  # agnostic to trailing slash
+def _lstrip_path_root(path, root):
+    rel_path = str(path)[len(str(root)) :]
+    return rel_path.rstrip("/")  # agnostic to trailing slash
+
 
+def _assert_glob_results_match(cloud_results, local_results, cloud_root, local_root):
     local_results_no_root = [_lstrip_path_root(c.as_posix(), local_root) for c in local_results]
     cloud_results_no_root = [_lstrip_path_root(c, cloud_root) for c in cloud_results]
 
@@ -119,6 +121,26 @@ def _assert_glob_results_match(cloud_results, local_results, cloud_root, local_r
     assert set(local_results_no_root) == set(cloud_results_no_root)
 
 
+def _assert_walk_results_match(cloud_results, local_results, cloud_root, local_root):
+    # order not guaranteed, so strip use top as keys for matching
+    cloud_results = {
+        _lstrip_path_root(top, cloud_root): [dirs, files] for top, dirs, files in cloud_results
+    }
+    local_results = {
+        _lstrip_path_root(Path(top).as_posix(), local_root): [dirs, files]
+        for top, dirs, files in local_results
+    }
+
+    assert set(cloud_results.keys()) == set(local_results.keys())
+
+    for top in local_results:
+        local_dirs, local_files = local_results[top]
+        cloud_dirs, cloud_files = cloud_results[top]
+
+        assert set(cloud_dirs) == set(local_dirs)  # order not guaranteed
+        assert set(local_files) == set(cloud_files)  # order not guaranteed
+
+
 def test_iterdir(glob_test_dirs):
     cloud_root, local_root = glob_test_dirs
 
@@ -138,6 +160,25 @@ def test_iterdir(glob_test_dirs):
     )
 
 
+def test_walk(glob_test_dirs):
+    cloud_root, local_root = glob_test_dirs
+
+    # walk only natively available in python 3.12+
+    local_results = local_root.walk() if hasattr(local_root, "walk") else os.walk(local_root)
+
+    _assert_walk_results_match(cloud_root.walk(), local_results, cloud_root, local_root)
+
+    local_results = (
+        local_root.walk(top_down=False)
+        if hasattr(local_root, "walk")
+        else os.walk(local_root, topdown=False)
+    )
+
+    _assert_walk_results_match(
+        cloud_root.walk(top_down=False), local_results, cloud_root, local_root
+    )
+
+
 def test_list_buckets(rig):
     # test we can list buckets
     buckets = list(rig.path_class(f"{rig.path_class.cloud_prefix}").iterdir())
@@ -155,10 +196,10 @@ def test_glob(glob_test_dirs):
     # cases adapted from CPython glob tests:
     #  https://github.com/python/cpython/blob/7ffe7ba30fc051014977c6f393c51e57e71a6648/Lib/test/test_pathlib.py#L1634-L1720
 
-    def _check_glob(pattern, glob_method):
+    def _check_glob(pattern, glob_method, **kwargs):
         _assert_glob_results_match(
-            getattr(cloud_root, glob_method)(pattern),
-            getattr(local_root, glob_method)(pattern),
+            getattr(cloud_root, glob_method)(pattern, **kwargs),
+            getattr(local_root, glob_method)(pattern, **kwargs),
             cloud_root,
             local_root,
         )
@@ -190,6 +231,21 @@ def test_glob(glob_test_dirs):
         dir_c_cloud.rglob("*/*"), dir_c_local.rglob("*/*"), dir_c_cloud, dir_c_local
     )
 
+    # 3.12+ kwargs
+    if sys.version_info >= (3, 12):
+        _check_glob("dir*/FILE*", "glob", case_sensitive=False)
+        _check_glob("dir*/file*", "glob", case_sensitive=True)
+        _check_glob("dir*/FILE*", "rglob", case_sensitive=False)
+        _check_glob("dir*/file*", "rglob", case_sensitive=True)
+
+        # test case insensitive for cloud; sensitive different pattern for local
+        _assert_glob_results_match(
+            dir_c_cloud.glob("FILE*", case_sensitive=False),
+            dir_c_local.glob("file*"),
+            dir_c_cloud,
+            dir_c_local,
+        )
+
 
 def test_glob_buckets(rig):
     # CloudPath("s3://").glob("*") results in error
diff --git a/tests/test_cloudpath_instantiation.py b/tests/test_cloudpath_instantiation.py
index 1ae100b..366607f 100644
--- a/tests/test_cloudpath_instantiation.py
+++ b/tests/test_cloudpath_instantiation.py
@@ -1,4 +1,7 @@
+import inspect
 import os
+from pathlib import PurePath
+import re
 
 import pytest
 
@@ -83,3 +86,59 @@ def test_dependencies_not_loaded(rig, monkeypatch):
 def test_is_pathlike(rig):
     p = rig.create_cloud_path("dir_0")
     assert isinstance(p, os.PathLike)
+
+
+def test_public_interface_is_superset(rig):
+    """Test that a CloudPath has all of the Path methods and properties. For methods
+    we also ensure that the only difference in the signature is that a CloudPath has
+    optional additional kwargs (which are likely added in subsequent Python versions).
+    """
+    lp = PurePath(".")
+    cp = rig.create_cloud_path("dir_0/file0_0.txt")
+
+    # Use regex to find the methods not implemented that are listed in the CloudPath code
+    not_implemented_section = re.search(
+        r"# =+ NOT IMPLEMENTED =+\n(.+?)\n\n", inspect.getsource(CloudPath), re.DOTALL
+    )
+
+    if not_implemented_section:
+        methods_not_implemented_str = not_implemented_section.group(1)
+        methods_not_implemented = re.findall(r"# (\w+)", methods_not_implemented_str)
+
+    for name, lp_member in inspect.getmembers(lp):
+        if name.startswith("_") or name in methods_not_implemented:
+            continue
+
+        # checks all public methods and properties
+        cp_member = getattr(cp, name, None)
+        assert cp_member is not None, f"CloudPath missing {name}"
+
+        # for methods, checks the function signature
+        if callable(lp_member):
+            cp_signature = inspect.signature(cp_member)
+            lp_signature = inspect.signature(lp_member)
+
+            # all parameters for Path method should be part of CloudPath signature
+            for parameter in lp_signature.parameters:
+                # some parameters like _deprecated in Path.is_relative_to are not really part of the signature
+                if parameter.startswith("_") or (
+                    name == "joinpath" and parameter in ["args", "pathsegments"]
+                ):  # handle arg name change in 3.12
+                    continue
+
+                assert (
+                    parameter in cp_signature.parameters
+                ), f"CloudPath.{name} missing parameter {parameter}"
+
+            # extra parameters for CloudPath method should be optional with defaults
+            for parameter, param_details in cp_signature.parameters.items():
+                if name == "joinpath" and parameter in [
+                    "args",
+                    "pathsegments",
+                ]:  # handle arg name change in 3.12
+                    continue
+
+                if parameter not in lp_signature.parameters:
+                    assert (
+                        param_details.default is not inspect.Parameter.empty
+                    ), f"CloudPath.{name} added parameter {parameter} without a default"
diff --git a/tests/test_cloudpath_manipulation.py b/tests/test_cloudpath_manipulation.py
index 1b40595..a6aad16 100644
--- a/tests/test_cloudpath_manipulation.py
+++ b/tests/test_cloudpath_manipulation.py
@@ -1,4 +1,5 @@
 from pathlib import PurePosixPath
+import sys
 
 import pytest
 
@@ -50,6 +51,12 @@ def test_relative_to(rig, azure_rig, gs_rig):
         assert rig.create_cloud_path("bucket/b/c/d.file").relative_to(
             rig.create_cloud_path("bucket/z")
         )
+
+    if sys.version_info >= (3, 12):
+        assert rig.create_cloud_path("bucket/path/to/file.txt").relative_to(
+            rig.create_cloud_path("other_bucket/path2"), walk_up=True
+        ) == PurePosixPath("../../bucket/path/to/file.txt")
+
     with pytest.raises(ValueError):
         assert rig.create_cloud_path("a/b/c/d.file").relative_to(PurePosixPath("/a/b/c"))
     other_rig = azure_rig if rig.cloud_prefix != azure_rig.cloud_prefix else gs_rig
@@ -73,6 +80,9 @@ def test_joins(rig):
     assert not rig.create_cloud_path("a/b/c/d").match("**/c")
     assert rig.create_cloud_path("a/b/c/d").match("a/*/c/d")
 
+    if sys.version_info >= (3, 12):
+        assert rig.create_cloud_path("a/b/c/d").match("A/*/C/D", case_sensitive=False)
+
     assert rig.create_cloud_path("a/b/c/d").anchor == rig.cloud_prefix
     assert rig.create_cloud_path("a/b/c/d").parent == rig.create_cloud_path("a/b/c")
 
@@ -107,6 +117,16 @@ def test_joins(rig):
     )
 
 
+def test_with_segments(rig):
+    assert rig.create_cloud_path("a/b/c/d").with_segments(
+        "x", "y", "z"
+    ) == rig.client_class().CloudPath(f"{rig.cloud_prefix}x/y/z")
+
+
+def test_is_junction(rig):
+    assert not rig.create_cloud_path("a/b/foo").is_junction()
+
+
 def test_equality(rig):
     assert rig.create_cloud_path("a/b/foo") == rig.create_cloud_path("a/b/foo")
     assert hash(rig.create_cloud_path("a/b/foo")) == hash(rig.create_cloud_path("a/b/foo"))